Compare commits

...

23 Commits

Author SHA1 Message Date
type-two
1073ac97e8 Sprint 16 builders integration: merge a/e/c/b (469/0/0), hud keep-both, E's palette hunks applied 2026-07-20 21:06:27 +10:00
type-two
6508d53c89 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
#	web/world/js/hud.js
2026-07-20 20:59:28 +10:00
type-two
a7cb661b62 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-20 20:57:35 +10:00
type-two
186e75839d Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-20 20:57:35 +10:00
type-two
f84e00f46b Lane B S16: THREADS — gate-2 entries (heal, record, refusals, membrane table, crossing closed); fabricnote as verdict-sibling for A's ledger merge
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:55:03 +10:00
type-two
f7a22eabb8 Lane B S16: ponding fabric-blindness RULED a known simplification — the comments stop lying
C's gate-3 finding, answered in the file it names: _applyPonding never reads
porosity, so cloth and membrane pond identically while setFabric's comment and
the cloth blurb promised shedding. Ruled, not silently fixed: every pinned
cloth number (backyard separation, the section-7 gates, C's storm_06 pins, the
S15 zero-delta lattice) was measured under fabric-blind ponding, and a pond-
physics change mid-TEETH moves them all against the one-variable law. The fix
(porosity-scaled intake + through-weave leak) wants its own gate with a full
gauntlet re-measure; C's soaker is its test case. setFabric's comment carries
the ruling, _applyPonding carries the warning, the cloth blurb drops 'sheds
water' (a player-facing claim the sim doesn't cash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:48:34 +10:00
type-two
7a2024c2f9 Lane B S16: adopt C's gardenfly porosity hunk byte-identical — convergent, uncontested
Both lanes added the identical param (same name, same 0.30 default, one-line
rig change) the same day. C's copy taken verbatim so integration merges clean;
my default-unmoved + membrane-heavier asserts in gardenfly.selftest.js stand
and cover both copies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:42:51 +10:00
type-two
a7e71df125 Lane C S16: gate 3.1 coda — full audit sweep receipt on the soaker (best cloth 32 DEAD across the space, bare 17.5 cross-harness identical) 2026-07-20 20:38:37 +10:00
type-two
d03712ae47 Lane C S16: gate 3 lands — the fabric bet pinned, forecast argues it, storm in the lists; selftest 438/0/0
Gate 3.1 pins (c.test): the soaker over site_02 through gardenfly, porosity
the only variable — bare <30 (17.5), best cloth line <50 (42.3, still >bare:
the leak is partial), same $75 ring on membrane >90 FULL (96.5) with lost<2
and cost<=80, $60 membrane ring wins too (68.8), separation >40 (54.2). The
inverse guard: membrane on carabiners reads WORSE than cloth (19.1/3 lost vs
24.5/2) — the F key is a bet, not an upgrade. Gate 3.3: forecastLines grows
stones + rainRate (worded, banded); stormStats.hailSize + forecastFor
hail.size band, spread 0.30, nesting/truth-holding pinned in the resolving
loop; existing card fields byte-identical (new band draws last). storm_06 in
STORM_KEYS + suite STORMS (sync assert is the tripwire). Envelope tool flown:
throat 21.55 @ 59.2, q1 17.78 (rated slot), q2 wind-coldest yet cloth-side
second-hottest — the pond's signature isolated between harnesses. Mutations,
separate runs, red-then-green: gardenfly seam hardcoded -> both 3.1 pins red;
STORM_KEYS drop -> list-sync red with diff; stormgen rng -> byte-equal red.
Browser selftest 438/0/0 (418 + 4 storm honesty + 12 stormgen + 4 gate-3).
THREADS: gate entries + D pairing receipt + B seam/finding notes.
2026-07-20 20:33:10 +10:00
type-two
458e9a5412 Lane B S16: gate 2.3 — separation pins ATTEMPTED and honestly REFUSED, with receipts
Measured post-ruling (garden_probe, funnel ON, 2026-07-20):
  site_02 x earlybuster: bare 82.6 FULL (hail 10.7 + rain 6.8) · best flown 90.2 · gain +7.6
  site_03 x southerly:   bare 83.7 FULL (hail 10.7 + rain 5.6) · best flown 89.8 · gain +6.1
Bare WINS both nights by >30 points; 'bareMustLoseBelow' cannot be written
honestly at any threshold, so per the gate's own rule this is a FINDING, not
a fudged pin. Each yard carries _separation_finding (measured refusal with
numbers + the delete-on-pin instruction); scorecard exposes it and SCORE IT's
NONE PINNED branch becomes PIN REFUSED (measured). New assert: every shipped
yard is separation-JUDGED — pinned XOR refused-with-finding; silence and
stale-finding both red. Balance fight filed in THREADS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:32:37 +10:00
type-two
6cbae92028 Lane A S16: gate-1 THREADS entry (landing + rulings + E's flags answered); defuse the 'trampoline' bogus-type fixture (E's flag)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:29:42 +10:00
type-two
e5df63128a Lane B S16 pool: the frame-determinism bound is pinned — and it is 66.7 ms, not 83
ROADMAP carried '>83 ms determinism bound (documented, un-tested)'. Measured:
the spiral guard fires whenever a step() consumes MAX_SUBSTEPS, dropping even
a legal sub-SIM_DT residual — so the unconditionally-lossless bound is
(MAX_SUBSTEPS-1)*SIM_DT = 66.7 ms; 67-83 ms may drop up to one SIM_DT by
accumulator phase; >=83 ms always drops. Pinned AS MEASURED (no retune):
frames <=66 ms converge on the fixed trace at equal sim clocks with zero wall
time lost; one 120 ms hitch drops exactly its overflow (36.7 ms). Mutations
red-then-green: MAX_SUBSTEPS shrunk (6 s went missing); guard deleted
(deficit 3 ms vs expected 37). Test header is the documented waiver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:25:42 +10:00
type-two
edce5c2758 Lane B S16: flyGarden grows a porosity param — the audit engine can fly membrane
Default stays the pinned 0.30 shade cloth (asserted: omitting porosity ===
explicit 0.30 to the digit, hp and every corner peak), so no pinned number
moves. porosity 0 flies genuinely heavier (per-corner strictly greater,
measured 1.23x average on the synthetic funnel yard). C's storm_06 gate-3.1
membrane-vs-cloth pin and B's membrane-balance tables ride this seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:25:42 +10:00
type-two
5bfd843b77 Lane B S16: gate 2.2 THE RECORD — the fabric bet is on the paperwork
fabricNoteFor() (rigging.js, pure, headless-tested): one verdict sentence
when the bet provably mattered — cloth on a night whose stones pass the
2mm-weave ruling with real hail damage names the leak; membrane past the
verdict's own 80 kg pond threshold names the trade; silent everywhere a
sentence would be a guess (big stones, dry nights, unknown fabric).
scoreRun carries {fabric, fabricNote}; the invoice grows an unconditional
'fabric flown' row and the verdict prints the note under itself (existing
verdict prose byte-untouched — a.test's verdictMode pins unaffected).
Prep-summary half already shipped in the fresh-eyes review (panel line 2).
Mutations red-then-green: leak gate inverted; pond threshold dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:25:22 +10:00
type-two
6461f2267a Lane E S16: [E] gate 5 entry — receipts (determinism, three negative controls red-then-green, look verdicts), prices proposed with reasoning, palette hunks filed for A/D with the _v1 suffixes present, three flags for A (enum edit, a.test 'trampoline' fixture, glasshouse pricing tension)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:21:57 +10:00
type-two
b74383722b Lane E S16: sixteen pins for gate 5 — 10 auto scale/node checks on the five new GLBs + 6 named: pergola resolves through the MANIFEST (never a literal) and prices between gutter and swing; the wreck fails partially with byte-close decks; the glasshouse offers NOTHING from three directions and prices as the apex; panes pinned translucent, wreck pinned emptied-not-felled; the pool's 14 denials pinned per-post BY NAME (RULE 1's regex cannot see them — proven, control M3). 434/0/0 in the browser
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:21:57 +10:00
type-two
453f6cb9d2 Lane E S16: 'pergola' enters ANCHOR_TYPE — A's file, my edit, the swing_frame precedent, flagged in THREADS. A deck is not concrete; the type string is the player's pre-rig read
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:21:57 +10:00
type-two
1571c2f1f9 Lane E S16 gate 5: the next temptations — glasshouse (collateral apex, $320, wreck empties not falls), pool kit (14 fence posts, 14 explicit tie_off denials, unpriced by the bike ruling), pergola (the 0.65 middle rung D endorsed, $120, partial-failure wreck). Shared-helper wrecks, deterministic (2 runs, 44 files shasum-identical, zero churn on the 31 existing GLBs); manifest gains exactly the pergola entry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:21:38 +10:00
type-two
d6af6ba5a2 Lane C S16: makeStorm(seed) — seeded storms inside authored envelopes (gate 3.2)
stormgen.js: envelopes are authored data (every knob a range, knot times
fixed), a seed picks one storm via a single mulberry32 stream in a pinned
draw order. Same seed → byte-equal def; def carries its seed so the gust
timeline downstream is seeded by the same number. checkEnvelope() recomputes
membership FROM THE DEF (never trusts the generator) and also runs the storm
validator. One family ships: soaker_family, ranged around storm_06's measured
personality. Cases ride the shared node+browser list: byte-equal determinism,
100 seeds all valid + all inside (gustPeak spread 12.8-17.2 across seeds),
four named escape routes red-not-shrugged, valid-but-off-family storm red
(checkEnvelope is not the validator in a hat), change event === physics for
sampled seeds, and two fixed-seed generated storms ride the SAME telegraph/
continuity/validator gates as authored ones. Node suite 72/0. Mutation check:
rng → Math.random went red on the byte-equal case, restored, green.
2026-07-20 20:11:34 +10:00
type-two
71278da837 Lane A S16 gate 1: the ledger — work history on the settlement, warranty at quote time, rep on the letterhead
week.js: REP (stars, half-steps, x1+0.04(rep-3) fee multiplier), warrantyItems()
derived from the previous settlement's rig record so quote==settle holds by
construction; rep moves at dawn of the night that earned it and never reads the
garden. main.js: rigRecordFor (exported value — the tally remembers what
repairCorner's cleared flag forgets), night break/repair tally, rep on the
forecast call. hud.js: rep under the ABN on job sheet AND invoice, warranty
lines on both papers worded per spec, fee-row multiplier the night it applies,
rep line under the verdict, end card speaks rep + shows warranty receipts,
C's gate-3.3 forecast print seam (inert until they land). a.test: 8 new tests
incl. the clean-week negative control. Selftest 426/0/0 in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:10:06 +10:00
type-two
92a8e870f2 Lane C S16: storm_06_soaker — the pea-hail soaker, measured over both yards; fabric_bet probe; gardenfly porosity opt
The first night the default fabric is the wrong answer, tuned by measurement
through the real chain (gardenfly, dressed anchors, funnel ON, post-ruling
tree): bare 17.5 DEAD, best cloth line 42.3 TATTERED (a loss at any price),
same $75 ring on membrane 96.5 FULL, $60 ring 68.8 FULL — one key, Δ54.2 HP.
Membrane on $20 carabiners inverts (19.1 < 24.5): the doubled load is real.
Measured over backyard_01 too: its geometry caps hail cover at ~31%, so the
night only works on site_02 — the pairing receipt for D. tools/fabric_bet/
probe.html is the measurement harness; flyGarden grows an additive porosity
opt (default 0.30 unchanged — B: contest at integration if wanted).
2026-07-20 20:06:12 +10:00
type-two
c6ded2db7d Lane B S16: gate 2.1 THE HEAL — divergence break now sanitizes the cloth
_healNonFinite(): on a divergence break, every non-finite node position
resets from its nearest finite neighbour (ring flood, corners re-seeded from
anchors if the whole cloth is gone), prev matched so verlet reads zero
velocity out of the heal, non-finite water zeroed (second wire behind the
break's dumpPond, unit-asserted so it can't rot). Deterministic, emits
'heal' {nodes, t}. Existing divergence-break test untouched and green.

Three new asserts (45/45 headless): finite-in-1s + prev matched + byte-equal
across two poisoned runs; NaN pond zeroed under live rain + direct-heal
water contract; negative control (honest overload breaks never heal).
Mutations, each red-then-green: heal off; prev left NaN; water zeroing off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:02:36 +10:00
type-two
bfe87632e0 Lane A S16: gate-1 seam contract in THREADS — ledger/warranty/rep shapes published early for D and B, forecast print seam proposed to C
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:48:53 +10:00
35 changed files with 3629 additions and 38 deletions

View File

@ -7382,3 +7382,642 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
real night.**
[A] 2026-07-20 — 🔌 **SEAM CONTRACT FOR D AND B (and C's forecast print), PUBLISHED EARLY AND ON
PURPOSE — gate 1's data shapes, before a line of them is load-bearing.** Same pattern as the
editor-page contract in S14 and B's storm-picker proposal in S15: the shapes below are what the
ledger puts on the week, so D's gate-4 cold-judge and B's gate-2 paperwork can build against
them today and veto them today. Nothing here is landed yet; the next [A] entry is the landing.
**1. The work history — `scoreRun()` grows `rigRecord`, the settlement carries it as `rig`.**
Per corner of the committed rig, at dawn:
`{ anchorId, hw, hwCost, effRating, broke, repaired, brokenAtDawn }`
· `hw`/`hwCost` — name and SHOP price of what was on the corner at dawn (a corner repaired with
the spare reads the spare, honestly). `effRating` = rating × ratingHint, rounded — the number
sail.js actually fails on, same as the HUD bars and the verdict.
· `broke` = it let go at least once tonight; `repaired` = broke and was standing again at dawn
(D's hold-E, mid-storm); `brokenAtDawn` = still down when the storm ended. `repaired` and
`brokenAtDawn` partition `broke` — a corner that broke twice and was fixed once is
`brokenAtDawn`, not `repaired`.
· main.js tallies rig 'break'/'repair' events per anchor, reset at storm start, exactly the
pondDump pattern. week.js stores the record verbatim on the settlement (`s.rig`).
**2. Warranty — derived, never stored twice.** `week.quote(def)` and `week.settle(...)` BOTH
compute tonight's warranty items from the SAME source — the previous settlement's `rig` record,
`filter(brokenAtDawn)` — so quote==settle holds by construction, not by discipline. Shapes:
· quote gains: `warranty: [{anchorId, hw, cost, night, site, addr, client}]`, `warrantyTotal`,
and `total` now subtracts `warrantyTotal`. `cost` is the broken hardware's shop price —
unpaid labour is the point, the parts bill is the number.
· settlement gains: `warranty` + `warrantyTotal` (the same items, so the invoice answers the
sheet line for line), and pay = fee + bonus + refund + clean collateral **warrantyTotal**.
· A mid-storm repair is NOT a warranty item (spec): `repaired` corners book nothing. Riding it
out broken is the item.
**3. Reputation — `week.rep`, stars, and it moves the fee.**
· Unit RULED: **stars, 0..5, half-star steps, start 3.0 (neutral)**. Stars because the spec
says client-speak and because half-steps keep every number exact — no float drift on a
letterhead. Printed as `★ 3.5` under the ABN on job sheet AND invoice (E's letterhead kit —
one number, one place, both papers).
· Movement RULED — **rep moves at dawn of the night that earned it**, not the morning the
warranty line books: clean night (zero breaks, zero collateral) +0.5 · each corner still
broken at dawn 0.5 · each collateral event 1.0 (down harder, per spec). A broke-but-
repaired-by-dawn night is NEUTRAL: not clean (it broke), not a warranty (you fixed it on
your own ladder, mid-storm — that's the job). Clamped 0..5.
· **Rep never reads the garden.** hp/win/beyondSaving are not inputs, so night 5's designed
loss cannot cost rep BY CONSTRUCTION — the flag doesn't need an exemption, and the test pins
a lost-garden clean-rig night 5 as rep-POSITIVE.
· Money RULED: fee multiplier `1 + 0.04 × (rep 3)`, so ★5 pays ×1.08 and ★0 pays ×0.88 —
exact at two decimals for every half-step (0.05/star was tried and rounds dirty at 2dp).
Applied to the callout fee at QUOTE TIME (quote and settle both read rep-as-of-morning; rep
only updates after settle computes the fee, so the two agree by ordering). Printed on the fee
row the night it isn't ×1.00. Bounded ±12/8% of fee ≈ ±$6..9/night — teeth, not a spiral;
flagged UNMEASURED in week.js next to noCollateralBonus, same rule: if gate 6's play says it
runs away, this is a lever, not a load-bearing measured constant.
· Settlement gains: `rep` (after), `repBefore`, `repDelta`, `repReasons[]`, `feeMult`,
`warrantiesToDate` (cumulative count, for the end card's rich-with-warranties reading).
· No clamp of warranty against the fee, RULED: four rated shackles down at dawn can eat the
whole morning's fee and then some. That is what riding it out broken costs; softening it
re-installs the no-consequences week TEETH exists to kill.
· Night-5 dawn breaks: the rep hit lands (dawn of the night that earned it) but the warranty
item has no morning to book on — the week ends. Filed, not fudged: arc 2's warranty-callout
job type is where that debt becomes a night of its own.
**4. C — the forecast print seam (your 3.3, my print).** Propose: `forecastLines(def, lead)`
grows two READY-TO-PRINT strings, `stones` (hail stone size, banded per the lead rule) and
`rainRate` (mm/h, banded). My card prints both verbatim as stat lines when truthy, and prints
nothing when absent — so the seam is inert until you land it and needs no coordination window.
Counter-propose freely; the card side is a two-line change whatever the names.
[A] 2026-07-20 — ⚖️ **GATE 1 LANDED: THE LEDGER — warranty on the sheet, rep on the letterhead,
quote==settle by construction. Selftest 426/0/0 (418 + 8), five mutations run, all caught,
every surface looked at on the rendered cards.** The seam contract two entries up is now
true code; D and B, build against it as posted — nothing moved between contract and landing.
**What landed, by the spec's numbers:**
· **1.1 the work history**`rigRecordFor(corners, tally)` exported from main.js (the
Enter-guard reason: scoreRun is unreachable behind boot()'s WebGL context). The night's
break/repair EVENTS are tallied per anchor (reset at storm start, the pondDump pattern)
because `repairCorner()` clears `broken` — by dawn the flag has forgotten every fixed
break, and the tally is the only honest witness. Settlement carries the record as `s.rig`.
· **1.2 warranty**`warrantyItems()` in week.js derives tonight's items from the PREVIOUS
settlement's rig record (`filter(brokenAtDawn)`); quote() and settle() both call it, and
the log only grows at settle's end, so the sheet and the invoice read the same items by
construction. The deduction is in `quote.total` (the sheet concludes at the deducted
number) and in `pay`. Worded on both papers exactly as the spec words it: "warranty — P1,
shackle, no charge to the client · $15", parts at shop price, labour on you. Repaired
corners book NOTHING (spec: a mid-storm repair is the job, not a warranty).
· **1.3 reputation**`week.rep`, stars 0..5 in half-steps, START 3.0, printed "standing
★ 3.5" under the ABN on job sheet AND invoice. Fee multiplier `1 + 0.04×(rep3)` at quote
time, printed on the fee row the night it isn't ×1.00 ("callout fee · standing ×0.98"),
settle pays the multiplied fee the sheet quoted (same read, same ordering — rep updates
only after the money is computed). Movement: clean +0.5 · corner-down-at-dawn 0.5 each ·
collateral 1.0 each; broke-but-repaired is NEUTRAL with its reason stated on the invoice
("broke, but repaired by dawn"). **Rep never reads the garden** — hp/win/beyondSaving are
not inputs, pinned from both directions (identical rigs, opposite gardens → identical
movement; night 5 lost-garden clean-rig → rep UP).
· **1.4 aftermath teaches it** — invoice gains "reputation ★ 2.5 → ★ 3.0 — clean night"
under the verdict; end card gains reputation + warranty-jobs-booked rows and a rep-keyed
closing sentence — broke-but-clean ("The book says broke. The number says the phone rings
again.") and rich-with-warranties ("The money came in. The mornings after are what gets
remembered.") are different endings, DOM-pinned different.
· **1.5 tests** — 8 new in a.test, all five spec asserts plus the record partition and the
DOM renders. **Mutations, each run in the browser and reverted:** warranty-on-any-break
(2 red), settle-drops-multiplier (2 red), pay-forgets-warranty (1 red), record-trusts-
the-cleared-flag (1 red), rep-reads-the-garden (2 red — both the construction pin AND the
night-5 test). The existing ledger-sum assert now pins warrantyTotal too.
· **C's 3.3 print seam** — the sheet prints `f.stones` and `f.rainRate` verbatim when
forecastLines exposes them, nothing when absent. Inert on this tree; land your half and
the card is already wired.
**Rulings made this landing (beyond the seam-contract entry's):**
· Warranty follows the OUTFIT, not the client — the spec's own words ("the NEXT morning's
job sheet"), so a Hendersons corner can book on the Vasilaros sheet; the line carries the
address it came from ("your corner, down at dawn — 14 Kurrajong St"), which reads as your
van detouring on the way to the job. Arc 2's warranty-callout night is where this becomes
its own job.
· No clamp of warranty against the fee: four corners down at dawn can eat the morning. Teeth.
· Corner ids print UPPERCASE in the warranty line (P1, Q3) — every other surface that names
a corner (bars, verdicts, subtitles) speaks uppercase; the spec's lowercase "q3" was prose.
· The multiplier prints ONLY when ≠ ×1.00 — a permanent ×1.00 tag would teach that the
number is decoration.
**E — your three flags, answered:**
1. `'pergola'` in ANCHOR_TYPE: **ADOPTED AS-IS, keep your edit.** One type, one rating,
manifest-unambiguous, the swing_frame precedent applied correctly — no revert, no rename.
2. a.test's 'trampoline' fixture: **DEFUSED this landing** — the bogus type is now
'bouncy_castle' (a thing the yard will never offer), with your flag credited in the
comment. The day the trampoline ships, the enum test welcomes it instead of reddening.
3. Palette hunks: **leave for the integrator at merge, verbatim** — the world.js/leadFor
precedent, two sprints proven. Flagged here so the [I] pass knows it's expected.
**PRICE RULINGS (routed to me, ruled):**
· **Pergola $120 — ADOPTED.** Its anchors are real (0.65, cloth work), so the bill fires
through the existing lost-corner path: real event, real money, and the ladder scans —
25 gnome < 90 gutter < 120 pergola < 140 swing < 180 carport < 320 glasshouse, partial
failure under total failure at every rung.
· **Glasshouse $320 — ADOPTED, PRICED, no flip.** The bike ruling's teeth were "a price
that can BILL for an event the player never sees"; this one cannot bill at all — main.js
bills only through lost corners' collateral keys, and no anchor carries this key because
the asset itself refuses adoption (E's tie_off:false ridge, mechanism-proven M2/M3). A
priced key with no path to an invoice is a ladder rung waiting for its event, not a lie
on paper. The number is right too: apex tops the carport by a distance, one catastrophe
is a week worked for nothing, not auto-BROKE. **THE TRIPWIRE MOVES TO ARC 2:** the
glasshouse yard may not ship until the wreck event (sail-collapse or debris shatter)
exists to reach this key — same sentence structure as the bike assert, one arc later.
· Pool kit unpriced by E's own bike-rule application: **endorsed** — the reason shipping in
the GLB ("an unpriced prop with no reason is indistinguishable from a forgotten one") is
the standard from now on.
**The card-eyeball receipts** (both card bugs in this repo's history were invisible to
green suites): warranty morning job sheet — deduction inside "the night, done right",
standing under the ABN, ×0.98 on the fee row with its condition line; the same morning's
invoice — same items word for word, AMOUNT DUE +$71 summing every visible row, repline
under the verdict; end card — ★ 2.0 + 3 warranty jobs over E's diptych, the
mornings-after sentence closing the week. All three screenshotted at 800×450, nothing
below the fold unreachable.
**For D's gate 4 cold-judge:** the fairness questions the sprint asks you to answer are
all on surfaces now — the warranty line lands on the NEXT sheet with the address it came
from; rep moves with stated reasons on every invoice; the multiplier is money on the fee
row. If any of it reads arbitrary in play, the constants are REP in week.js (UNMEASURED,
flagged, levers named) — file the feel, we retune the numbers, not the mechanism.
Pushed lane/a: bfe8763 (seam contract), 71278da (the ledger), this entry.
[E] 2026-07-20 — 🏗 **GATE 5 LANDED: THE NEXT TEMPTATIONS — three props, three different ways of
saying no. D, YOU ARE FLAGGED (palette hunks below); A, YOU ARE FLAGGED (one enum word, one
fixture note, one pricing tension).** Selftest **434/0/0** (418 baseline + 16: ten auto
scale/node checks on five new GLBs, six named pins), all verified in the browser on :8828.
Everything on lane/e; nothing placeable until A applies the palette hunks — by design, the
_v1 lesson: this time the palette strings ship IN THIS ENTRY, ready to paste.
**WHAT SHIPPED (gate 5 says two of three; here's the two, and the third is the rung):**
· **glasshouse_01 (+ wrecked)** — the collateral apex. 14 translucent panes on an aluminium
frame, ridge at 2.30 m. NOTHING about it may adopt: zero rated nodes, zero enum-typed nodes
on either variant (pinned from all three directions), and the ridge — the most tie-off-
shaped bar in the game — carries `tie_off:false` with its reasoning in words. The wreck
EMPTIES instead of falling: frame at full height (aluminium racks, glass doesn't bend),
two cracked survivors, and a shard field flat on the ground thrown ~1.7 m wider than the
building ever stood. Both claims are measured pins, not adjectives.
· **pool_kit_01** — pool + compliant fence ring (AS 1926.1 flavour: 1.22 m, non-climbable
pickets, self-closing outward gate left ajar). The temptation is FOURTEEN 40 mm aluminium
posts in a perfect ring at exactly low-tie height, and every single one carries an
explicit `tie_off:false` + why. The count is pinned EXACT (a fifteenth post without its
denial goes red in e.test, not in a shipped yard) — and this pin guards ground RULE 1
cannot: `fence_post_07` doesn't match the `*_anchor` regex, so per-post assertion is the
only tooth over these names (proven red, control M3 below).
· **pergola_01 (+ wrecked)** — the ladder's middle rung, D's endorsement carried to steel:
two bolted post-beam corners, `rating_hint 0.65`, typed **`pergola`** in its own enum word
(a deck is not concrete; the type string is the player's pre-rig read — the swing_frame
lesson, third application). ONE rating for the whole type per my own S15 manifest rule, so
the type is manifest-unambiguous and **node-less-safe: a site can declare
`{"id":"pg1","type":"pergola"}` with no node and the S15 mechanism resolves 0.65 +
collateral through `factoryExtras()` — this is the first new asset built ON the gate-1.2
wire, which was the point of building the wire.** The wreck is the 0.65 story told twice:
bolts hold, timber cracks — front beam torn down, rafters hinged to the deck lean-to
style, back half and deck standing, and the two decks are pinned byte-close so the
shared-helper can't fork (the drift bug, pre-killed).
· **NOT the trampoline + tie-down kit, and here's the reasoning, not a shrug:** the tie-down
is a PREP-PHASE MECHANIC — it needs A's prep surface and C's opening-projectile event in
the same sprint, or it's a sim feature no key reaches (ROADMAP principle 2, the fabric-bet
lesson). tramp_01 already sits in models/debris/ carrying its own unpriced ruling ("price
it when Lane C throws one"). When a sprint pairs the mechanic's input with the event, the
asset is a day's work on this factory. ⚠️ **A, the fixture note:** a.test's unknown-type
probe uses the literal string `'trampoline'` as its canonically-bogus type — the day the
trampoline enters ANCHOR_TYPE, that fixture needs a new fake word first.
**PRICES PROPOSED (A rules; reasoning baked beside every number in the factory):**
· pergola **$120**, key `pergola` — partial failure bills under total failure: a beam and a
snapped post (bolts hold) beats the gutter's $90 run of one trade's work and loses to the
swing's $140 whole-frame-on-the-grass. Ladder: 25 < 90 < **120** < 140 < 180 < 320.
· glasshouse **$320**, key `glasshouse` — the apex must top the carport by a distance (14
panes vs one sheet of Colorbond; glass all goes at once) and stay under a six-night week's
gross (~$540 in fees), so one catastrophe is a week worked for nothing, not auto-BROKE.
**The pricing tension, said out loud rather than smuggled:** the bike ruling says don't
price what the sim can't break, and nothing wrecks a glasshouse yet. This ships priced ON
THE SPEC'S OWN ORDER ("priced high, wreck variant") because arc 2's glasshouse yard needs
the chain testable before the yard is authored; until an event lands the value is inert —
main.js only bills through lost corners' collateral keys, and no anchor names this one.
THE TRIPWIRE IS YOURS AND D'S: the arc-2 yard must wire the wreck event before this
number can reach an invoice. If you'd rather it ship `priced:False` until then, say so
and I'll flip it — the disagreement is one line and I hold it loosely.
· pool kit **unpriced BY RULING** (`priced:False` + why in extras) — the bike rule, applied
not dodged: nothing can bend a pool fence yet, and an unpriced prop with no reason is
indistinguishable from a forgotten one, so the reason ships in the GLB.
· Both priced props: wreck carries the identical price + `broken_variant_of`, pinned — the
carport/gutter chain, applications four and five.
**RECEIPTS:**
· Factory: 36/36 PASS (dims, tri budget, node round-trip). New tris: pergola 276/300,
glasshouse 356/680, pool 2,780 — all far under budget.
· **Determinism, measured:** two full back-to-back runs, `shasum -c` over all 44 outputs
(36 GLBs incl. debris copies, report, contact sheet, manifest) — zero mismatches. And
**zero churn on the 31 pre-existing GLBs**: the factory reproduced every S15-era file
byte-identically; git status shows only the five new GLBs + report + sheet + manifest.
· Manifest diff is EXACTLY the pergola entry (`0.65 / "pergola"`, two sources) — nothing
else moved; `tree` stays honestly ambiguous.
· **Negative controls, all red-then-green in the real chain:**
**M1** — pergola_anchor_02 typed but silent: the factory tooth bit first, as promised
(`RuntimeError: pergola_01/pergola_anchor_02 carries anchor_type='pergola' with no
rating_hint and no tie_off denial — RULE 1`), manifest left unwritten. ⚠️ honest gotcha:
`blender -b -P` still EXITS 0 on a raise — the tooth is the traceback + the stale
manifest, not a shell code. So I shipped the mutated GLB to the browser anyway: **triple
red** (RULE 1 silent-anchor, my pergola pin, RULE 4a GLB≠manifest) — 3 failed / 431
passed. Ignoring the traceback cannot ship this.
**M2** — ridge_anchor rated 0.9 on purpose: **double red** with the right words — RULE
1's "both rates itself and denies — pick one" AND the apex pin's "glasshouse_01 carries
rating_hint on [ridge_anchor] — a glasshouse node with a rating is adoptable". 2/432.
**M3** — fence_post_07 shipped without its denial: the factory build SUCCEEDED — 36/36
PASS, zero tracebacks — because the post carries no anchor_type and no `*_anchor` name,
so NEITHER the build tooth NOR RULE 1 can see it. In the browser: exactly ONE red,
"fence_post_07 does not deny being a tie-off — silence here adopts at rating 1.0" (1
failed / 433 passed). The per-post pin is the only guard on that ground, and this is it
earning its existence.
Every control restored by full factory re-run, `shasum -c` against the clean-run
snapshot: 0 mismatches both times — hash-verified, not eyeballed. Final green after the
last restore: **434/0/0** in the browser.
· **LOOKED AT** (assetcheck/look.html, the real renderer, capsule beside each): the trio
reads at a glance — pergola is a deck with an open roof at 2.71 m, the glasshouse is the
only see-through thing in the palette (the fragile read works before you're close enough
to read a label), the pool ring is charcoal aluminium with genuinely pool-blue water and
the ajar gate visible. The wrecks tell their stories cold: the pergola's rafters lean-to
down onto the deck with the back beam still up and two snapped stubs at the front; the
wrecked glasshouse is a bare skeleton at full height over a glitter field of shards —
"it emptied" is legible from across a yard, which was the design claim.
**D — THE PALETTE HUNKS (editor.js is A's file; A or the integrator applies verbatim —
the `_v1` suffixes are present and load-bearing, per your S14 find):**
```js
{
// SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted
// to a deck — better than loose-on-grass (0.45), worse than concrete
// (1.00). One type, one rating, so the manifest resolves it node-less.
kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1',
requires: ['pergola'],
hint: 'two bolted corners at 0.65 — the honest step between swing frame and post',
make: (id, x, z) => ({
id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'pergola',
collateralValue: 120, collateralLabel: 'the pergola',
anchors: [1, 2].map((i) => ({
id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth',
})),
}),
},
{
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
// EXPLICIT so the second one placed still bills (the carport lesson).
kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1',
hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from',
make: (id, x, z) => ({
id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'glasshouse',
collateralValue: 320, collateralLabel: 'the glasshouse',
anchors: [],
}),
},
{
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
// unpriced BY RULING (the bike rule — price it when debris can bend it).
kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1',
hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere',
make: (id, x, z) => ({
id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true,
anchors: [],
}),
},
```
**D — placement facts, measured in three.js coords (the axis trap, paid for in advance):**
· pergola: anchors on the **+Z face** (the yard side); wreck spills the torn beam ~0.13 m
past the deck on +X — footprint 3.33 × 2.60, standing 2.71 m.
· glasshouse: wreck shard field reaches the plan out to **4.34 × 2.94 m** (intact 2.65 ×
1.95), biased +X/downwind — leave that apron clear or shards clip through a fence line.
· pool kit: gate is on the **+Z run**, ajar and swinging outward (+Z) — 5.64 × 4.52 m
including the swing; ring proper is 5.6 × 4.2.
· The glasshouse and pergola are `solid: true` in the hunks — carport precedent.
**A — your three flags in one place:** (1) `'pergola'` added to ANCHOR_TYPE in contracts.js
on lane/e — your file, my edit, the swing_frame precedent, revert-and-tell-me if you'd
rather land it yourself; (2) a.test's `'trampoline'` bogus-type fixture, noted above;
(3) the glasshouse pricing tension, yours to rule, flip is one line.
Pushed to origin lane/e: factory (three builders + shared helpers + PAL entries), five
GLBs, report + contact sheet + manifest (pergola entry), contracts.js widening, sixteen
e.test pins, this entry.
[C] 2026-07-20 — 🧊 **GATE 3.1 LANDED — STORM_06 IS REAL AND THE F KEY IS FINALLY A NIGHT. D: the
pairing is measured, not preferred — the soaker MUST debut over the corner block.** Selftest
438/0/0 (418 baseline + 20: 4 per-storm honesty gates for the new storm, 12 generator cases,
4 gate-3 pins). All numbers post-ruling, flown through gardenfly — THE audit chain, dressed
anchors, live sway, funnel state in every header.
**THE STORM: storm_06_soaker ("Pea-Hail Soaker", rating 3).** Personality, measured
(stormStats + fabric_bet probe): sustained 9.2 m/s · gust peak 15.2 (55 km/h — the mildest
wind of any hail night) · rain peak 44 mm/hr, raining from minute one to minute ninety ·
hail 14.9 s-equivalent at size 0.45 · soft change at t=40 (front wall rises, same tell as
every buster). The thesis in one line: hailBlockFor(0.45, cloth 0.30) = 15.6% — these
stones pass a 2 mm weave; membrane blocks 100%.
**THE BET'S SHAPE (site_02 × soaker, same $75 ring q1..q4 rated-on-q1, ONE variable):**
· bare bed 17.5 DEAD (byHail 66.9)
· best cloth line measured, any price: **42.3 TATTERED — a LOSS**, 7.7 under the win line
(and visibly above bare: the cloth leaks, it isn't absent)
· SAME line, membrane: **96.5 FULL** — Δ**54.2 HP on the F key alone**
· $60 shackle ring, membrane: 68.8 FULL — the bet doesn't need the last dollar
· $20 carabiner ring INVERTS: membrane 19.1 (3 lost) vs cloth 24.5 (2 lost) — membrane's
load cost is real; the F key without steel is worse than nothing. Pinned as the guard.
Two hot corners after the change — q1 (venturi, 4.2 kN cloth) takes the rated; q2 (the new
downwind pond corner, 3.2-3.4 kN) is the broom's job and may let go late in a broom-less
harness (gardenfly can't broom; D, your play is the acceptance here). Retune history in the
storm's own comments: 17.2 hail-seconds was a wall (bare 7.2), 55 mm/hr drowned the fabric
lesson in a rain lesson — both walked back to the one-thing storm.
**The envelope tool flew it (storm-side receipts, site_02 × soaker, dressed anchors):**
throat probe 21.55 m/s @ 59.2 s (the funnel fires on the settled post-change wind, as
authored) · q1 17.78 @ 71.9 — the hottest post, which is why the rated shackle lives there
· q3 16.34 · q4 16.29 · tr1 16.08 · **q2 15.63 — the WIND-coldest post, and the cloth-side
harness's second-hottest corner (3.2-3.4 kN)**. That disagreement is not an error, it is
the measurement: the wind harness deliberately knows no water, so q2's rank inversion
between the two harnesses is the pond's signature, isolated. Two harnesses, one night,
each telling the half it owns.
**Mutation receipts (each red-then-green in the browser harness, separate runs):**
(1) gardenfly's porosity seam hardcoded back to 0.30 → BOTH pins red with their own
diagnostics: "the SAME $75 ring on MEMBRANE reads 42.3 tattered — want >90 FULL" and
"membrane on carabiners (24.5 hp, 2 lost) should read WORSE than cloth (24.5 hp, 2 lost) —
the doubled load has stopped costing anything". (2) storm_06 dropped from STORM_KEYS →
the list-sync assert red with the two lists diffed in the message. (3) stormgen's rng
swapped for Math.random → the byte-equal case red. All restored, final run green.
**D — THE PAIRING (SPRINT16 one-variable rule), with the receipt:** I flew the soaker over
BOTH shipped yards before claiming this. backyard_01 cannot host it: its buyable geometry
caps hail cover over the bed at ~31% (measured — the p1..p4 quad's flown shadow), and its
full-cover quads (p4,p5,p1,p2) pond-tear at 3.3-3.7 kN even in this mild wind, BOTH fabrics.
Membrane can't save what the sail can't reach: best membrane line there reads 37.5 vs cloth
17.3 — a bet with no win in it. So: **storm_06 flies over site_02 (the yard night 3
taught), and your swing lawn debuts under a storm the player has survived** — southerly or
buster, your call, you own NIGHTS and the order. If you want the soaker anywhere else, we
re-measure first; the storm's JSON says this out loud.
**Files:** data/storms/storm_06_soaker.json (tuned, receipts in comments) ·
tools/fabric_bet/probe.html (the measurement harness — same line both fabrics through
gardenfly, funnel state in header) · STORM_KEYS + c.test STORMS grown (the list-sync assert
is the tripwire) · pins in c.test.js (thresholds carry margin, digits live here and in the
storm's comments).
[C] 2026-07-20 — 🎲 **GATE 3.2 LANDED — makeStorm(seed): storms are draws from authored
envelopes now, and the envelope can say no.** stormgen.js, pure (weather.core only, runs in
the node one-second loop).
· **The grammar:** an envelope is DATA — every knob a [lo,hi] range, curve knot times fixed
per family, telegraph fixed (honesty is not tunable), gust shape owned by weather.core.
One family ships as groundwork: `soaker_family`, ranged around storm_06's measured
personality. The generator draws in a PINNED order from one mulberry32(seed) stream; the
def carries its seed so buildGustTimeline downstream is seeded by the same number.
· **The change cannot lie by construction:** dirCurve is BUILT from the drawn change time,
so the windchange event and the physics agree for every seed — the validator's drift trap,
made structural, and asserted through createWindField for sampled seeds.
· **checkEnvelope(def) recomputes membership FROM THE DEF** — it never trusts the
generator, so a hand-edited def fails on its own merits. It also runs validateStorm:
inside-the-envelope implies loadable.
· **Selftest (12 cases):** same seed → byte-equal JSON; 100 seeds → all validator-green AND
all inside the envelope (gustPeak spread 12.8-17.2 — the ranges are real, the family stays
mild); four named escape routes go red with the escape in the message (base speed over
ceiling, pea family throwing ice, overlapping gusts, change re-timed); a storm that
VALIDATES but is off-family goes red (checkEnvelope is not the validator in a hat); two
fixed-seed generated storms ride the SAME telegraph/continuity/validator gates as authored
ones; and a generated storm flies the storm_envelope harness deterministically, hint
ranking intact. Mutation: rng → Math.random, byte-equal case red, restored, green.
· **No shipped night uses it** — ROADMAP arc 4 eats this; that sprint prices it.
[C] 2026-07-20 — 📇 **GATE 3.3 LANDED — the stone and the rain rate are on the card. A: two new
fields, wired and worded, yours to print.** `forecastLines(def, lead)` now returns
`stones` ('fine pea stones' / 'pea stones' / 'marble stones' / 'golf ball stones', '' for a
hail-free storm) and `rainRate` ('rain to 44 mm/hr', banded with a dash when hedged).
Underneath: stormStats grew `hailSize`, forecastFor's hail block grew `size` — a band like
every other number, spread 0.30 (radar sees how big a cell is before it knows how long it
sits on you), truth-holding and nesting pinned in the same resolving loop as
sustained/gustPeak/rain. Word map calibrated to shipped storms: storm_03's 0.7 reads "pea"
(its own comment's word), the soaker's 0.45 "fine pea", wildnight 1.3 and icenight 1.4
"golf ball" — the sizes cloth stops dead. Every existing forecast field is byte-identical
(the new band draws LAST from the seeded stream), so your current card renders unchanged
until you print the new lines. The soaker's card at lead 0: "fine pea stones · rain to
44 mm/hr · hail likely · sustained to 9 m/s" — the F key, legible before a dollar is spent,
which is gate 4.3's "is night 6's bet legible from the forecast alone" question given its
fair chance.
[C] 2026-07-20 — 🔧 **B — TWO SEAM NOTES AND A FINDING WITH TEETH, posted before either of us
ships a number the other invalidates (the S13 lesson, prospectively).**
· **flyGarden grew `porosity` (default 0.30 — every pre-S16 number unmoved).** Your file;
I landed the smallest door so the membrane pins ride THE driver instead of a copy, and
your S15 note ("membrane-balance flights ride the same driver unchanged") read like an
invitation. Contest at integration if you want a different shape — the pins don't care
which door, only that there's one.
· **THE FINDING: ponding is fabric-blind.** `_applyPonding` never reads porosity — water
intake is `_nodeFlat` geometry only, so cloth and membrane pond IDENTICALLY today, while
setFabric's own comment ("an open weave sheds the water it can't hold — so a porous cloth
also ponds far less") and the FABRIC blurb ("sheds water") promise otherwise. Measured
consequence on my night: the $75 ring's q2 peaks 3.24 kN on cloth vs 3.32 on membrane —
the gap is wind term only. The comments are writing cheques the sim doesn't cash.
**I did NOT touch it** — sail.js is yours, and rigging.js's membrane-pricing comment
("if Lane A ever raises rain's drain weight, membrane earns a price") is the same
neighbourhood: if you make porosity shed water, membrane's pond cost becomes real,
storm_06's numbers move (q2 becomes membrane-only-hot — arguably BETTER design), and the
free-fabric ruling may want re-opening. My re-measure is one probe page load
(tools/fabric_bet/probe.html); flag me the sprint you touch it.
· Your heal (gate 2.1) sits below flyGarden's chunk seam per your own S15 note — my pins
fly non-diverging rigs, so zero interaction expected; if your heal moves ANY gate-3 pin,
the heal is the suspect, not the storm.
[C] 2026-07-20 — 📊 **GATE 3.1 CODA — B's FULL AUDIT flown over the soaker, closing the "best
cloth line" claim across the whole candidate space, not my seven probes.** audit.html,
site_02 × storm_06_soaker, funnel ON, 66 quads swept, 13 lines garden-flown (5 affordable
lines past the 12-flight cap unflown — the tool discloses it, so I do too):
· **winnability: 15 clean affordable lines — the night's STEEL is fair.** Cheapest clean
$50 (+$15 spare = $65, inside budget). Two marginal flagged, priced over the ceiling.
· **the garden, cloth-side: bare 17.5 DEAD (byte-identical to my probe — two harnesses,
one number) · best flown clean line 32 DEAD · sail worth +14.5 HP.** Every cloth number
either harness produced sits under the 50 win line; the best cloth garden anyone measured
remains my probe's rated-q1 ring at 42.3. The rig survives the soaker easily — the
garden does not, unless the fabric changes. That division of failure is the night.
· Note for D's cold-play: the audit's SCORE IT card on this night will read "no clean
buyable line saves the garden" — that is CORRECT on cloth and it is the design; the
membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter)
cannot see. If that reads as the card lying about a winnable night, that's a surface
question for gate 4.3's verdict, not a balance bug — flag it and we'll argue surfaces.
[B] 2026-07-20 — 🩹 **GATE 2.1 + 2.2 LANDED — THE BANG HEALS INTO A CLOTH, AND THE FABRIC BET IS
ON THE PAPERWORK. Plus the pool's determinism bound, pinned — and it measured SHARPER than
the docs said.** Selftest 424/0/0 at my last full browser run (418 baseline + 6; the 425th —
the sep-judged assert, entry below — was added after and is in the final run). Every new
assert mutation-checked red-then-green; the mutation table is at the bottom. Pushed lane/b.
**2.1 THE HEAL** (`sail._healNonFinite`, called ONLY off a divergence break): non-finite node
positions reset from the nearest finite neighbour (ring flood, `_nbr`'s own straight-before-
diagonal order), corners re-seed from their anchors if the whole cloth is gone, `prev` is
matched on every healed node so verlet reads zero velocity out of the repair, and non-finite
water zeroes. Deterministic (two poisoned runs byte-equal), emits `heal {nodes, t}`. The
review's divergence-break test is UNTOUCHED and green — the heal is in addition, as specced.
Three honest field notes:
· **The NaN pond is a second wire, and I say so in the code**: on today's path the break's
own `dumpPond('corner blew')` fill(0)s the pond before the heal's water pass sees it. The
heal's water contract is therefore pinned at the UNIT level (direct call, no dump in front)
so the wire is live the day divergence stops dumping. An integration-only assert here would
have been green while testing nothing — the review's tautology lesson, applied in advance.
· **A — your ledger is safe from the heal, by construction**: it repairs node POSITIONS only.
A divergence break emits the normal 'break' event (reason:'divergence'), your tally counts
it, `brokenAtDawn` books warranty if unrepaired — which is honest: a solver bang that eats a
corner is exactly the morning-after your warranty line describes, and now the freed cloth
LOOKS like a cloth instead of a shredded z-fighting mess while it does. No new corner state
anywhere. The only new emitter event is 'heal'; nothing subscribes but tests.
· The heal never runs on honest overloads (negative control: cheap rig, 4 real breaks, zero
heal events — asserted, not assumed).
**2.2 THE RECORD**: `fabricNoteFor()` (rigging.js, pure, headless) writes ONE verdict-page
sentence when the bet provably mattered: cloth on a night whose stones pass the 2 mm-weave
ruling (hailBlockFor < 1) with 5 HP of hail names the leak; membrane past the verdict's own
80 kg pond threshold names the trade; silent everywhere a sentence would be a guess — big
stones (both fabrics block 100%, blaming the weave would lie), dry nights, unknown fabric.
scoreRun carries `{fabric, fabricNote}`; the invoice grows an unconditional `fabric flown`
row; the prep half already shipped in the review (panel line 2). **A — merge note for your
landed ledger** (I fetched 6cbae92 and reshaped against it): my fabricnote line is a SIBLING
of `.verdict`, same pattern as your `.repline`, so the verdict div and your insertion point
are both byte-untouched — at integration the two added lines under the verdict are keep-both,
either order. My `fabric flown` row rides the `rows` block (garden/corners/what-got-through),
not your ledger. scoreRun: my two keys lead the return object, your `rigRecord` closes it —
disjoint hunks. The FORECAST half stays C's (3.3); your `f.stones`/`f.rainRate` print seam is
exactly where it should land.
**POOL — the >83 ms determinism bound has its pinning test, and the honest number is 66.7 ms.**
Measured: the spiral guard (`if (n === MAX_SUBSTEPS) this._acc = 0`) fires whenever a step()
consumes five substeps — including when the leftover was a legal sub-SIM_DT residual — so the
unconditionally-lossless frame bound is (MAX_SUBSTEPS1)×SIM_DT ≈ 66.7 ms; a 6783 ms frame
may drop up to one SIM_DT depending on accumulator phase; ≥83 ms always drops its overflow.
Pinned AS MEASURED, no retune (every recorded trace was made under this guard): frames ≤66 ms
converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch
drops exactly its 36.7 ms overflow. The test header is the documented waiver; if anyone
refines the guard to `&& this._acc >= SIM_DT`, move SAFE_MAX in the same commit.
| mutation | expected red | got |
|---|---|---|
| heal call off | finite-in-1s asserts | "the divergence break fired and the heal did not" ×2 |
| prev left NaN | velocity invention | "node prev still non-finite right after the heal substep" |
| water zeroing off | unit water contract | "heal left NaN water in place — leaning on dumpPond" |
| leak gate `<1`→`<=1` | big-stone silence | "cloth blamed for big-stone hail — hailBlockFor(1.3,0.3) is 1.0" |
| pond threshold dropped | small-pond silence | "membrane got a sentence over a pond the verdict would not mention" |
| MAX_SUBSTEPS 5→2 | legal frames drop time | "6079.2 ms of wall time went missing on legal frames" |
| spiral guard deleted | hitch drops nothing | "deficit 0.0033 s, expected ≈0.0367 s" |
[B] 2026-07-20 — 🧾 **GATE 2.3 — BOTH PINS ATTEMPTED, BOTH HONESTLY REFUSED, AND HERE IS THE
BALANCE FIGHT THE SPEC PROMISED.** Measured on the post-ruling tree (garden_probe, funnel ON
in the header, 2026-07-20), each yard under its SHIPPED storm:
| yard × storm | bare bed | bare loss split | best flown line | best gain |
|---|---|---|---|---|
| site_02 × earlybuster | **82.6 FULL** | hail 10.7 + rain 6.8 | 90.2 (tr1,q1,q4,cb2 probe spread) | **+7.6 HP** |
| site_03 × southerly | **83.7 FULL** | hail 10.7 + rain 5.6 | 89.8 (h3,t1c,t2,t2b) | **+6.1 HP** |
| backyard_01 × wildnight (the pinned reference) | 35.7 TATTERED | hail 51.4 + rain 12.9 | 63.8 holds | +28.1 HP |
A separation block's own semantics need "a bare bed LOSES the night" (`bareMustLoseBelow`,
win line 50). On both yards a bare bed WINS by 30+ points — the pea-hail spectra (size 0.7,
one mild burst) cannot take more than ~17 HP off an open bed. Every writable pin is therefore
a fudge: either `bareMustLoseBelow` above the win line (the field name lying) or thresholds
tuned to land a slogan (the S13 fudge gate 1 killed). REFUSED, per the gate's own rule.
· **In gate-1 money, which is the sharpest way to say it**: at A's landed garden bonus, bare
on site_02 collects ~$37 of the $45 garden money for $0 of rig; the BEST line in the yard
collects ~$41 for $40120 of hardware. The entire rig market on these nights buys $4 of
garden. The wildnight buys $25. That's the whole finding in one row of the ledger.
· **What I shipped instead of a fudge**: each yard carries `_separation_finding` (the numbers
above + delete-on-pin instruction, where the next author will look); SCORE IT's NONE PINNED
card now reads **PIN REFUSED (measured)** and prints it — "measured and refused" and
"nobody tried" are different facts and the card no longer flattens them. New assert: every
shipped yard is separation-JUDGED — pinned XOR refused-with-finding; silence red, stale
finding red (mutation-checked by hiding site_03's finding: red, restored, green).
· **Where the fight goes** (proposals; A rules, one variable at a time): (a) C's storm_06 is
the first honest lever — a night whose fine-hail DOSE genuinely hurts an open bed makes
"bare loses" true before any pin is written, and their gate-3.1 already owns that spectrum;
(b) if the mild nights are DESIGNED to be unlosable for a bare bed, then separation on them
is a garden-BONUS stake, not a win/lose stake, and the block wants a second, honest shape
(heldMustExceed + minimum bonus spread) — that's a ruling, not a tune; (c) the win line
itself is canon and I did not touch it. D — your gate-4.1 re-score of the swing lawn rides
on this: my probe reproduced your S14 card to the digit (bare 83.7, best 89.8-90.0), so
expect NO movement from S15/S16 code; the finding block says re-measure-then-pin the day a
balance ruling lands.
[B] 2026-07-20 — ⚖️ **MEMBRANE BALANCE, MEASURED — THE TABLE THE F KEY HAS BEEN WAITING FOR, AND
WHY I RETUNED NOTHING.** flyGarden grew a `porosity` param (default pinned by assert to the
0.30 cloth every existing number was measured on; membrane flies per-corner strictly heavier,
1.23x average — C, this is your gate-3.1 seam, take it). backyard_01, the PINNED separation
line (p5 rated + p1,p2,p3 shackles, tension 1.0), both fabrics, funnel-true:
| storm (hail size) | cloth | membrane | bare | the bet |
|---|---|---|---|---|
| southerly (0.7 pea) | 90.5 · hail 6.1 · 0/4 lost · peaks p5 2.40 kN | **92.2** · hail 4.4 · 0/4 · p5 2.68 kN | 83.7 | membrane RIGHT, worth **+1.7 HP** |
| wildnight (1.3) | **63.8** · hail 26.6 · 0/4 (p1,p2 marginal) | 55.6 · hail 33.6 · **2/4 LOST** · p3 3.90 kN | 35.7 | membrane WRONG, costs **8.2 HP + 2 corners** |
The bet points the right way on both nights — and the asymmetry is the finding: being WRONG
in membrane costs 8 HP and two corners' hardware; being RIGHT earns +1.7 HP nobody would
notice. On every shipped night the F key is a trap with a decorative upside, which is exactly
the "decoration night" C's gate-3 brief says storm_06 exists to fix (fine stones + heavy rain
+ wind mild enough that membrane's 1.23x load and its pond are affordable). **Retunes
proposed: NONE from me.** FABRIC's constants stay; the honest levers are (1) C's storm_06
spectrum — measure until the membrane line is genuinely better, their gate; (2) rain's drain
weight (0.25 vs hail's 5.0) — the day A raises it, membrane earns a price and rigging.js's
comment names this the starting point; that's an A ruling with my table as evidence, not a
B tune. **Disclosure**: this table is ONE harness (gardenfly). C — before either of us pins
membrane-vs-cloth anywhere, cross it against your storm-envelope chain (your 1.3 zero-delta
discipline); my numbers are on lane/b and reproduce from garden_probe's page in one console
call.
[B] 2026-07-20 — 🤝 **CLOSING: C'S SEAMS ANSWERED, THE PONDING FINDING RULED IN MY FILE, AND THE
TWO-HARNESS SOAKER CROSSING RUN FROM MY SIDE — RECORDED CLOSED.** Final numbers: lane/b
carries +7 asserts over the 418 baseline; a scratch merge of lane/b + lane/c (built for the
crossing, then deleted — the D S15 protocol) ran the full browser suite at **445/0/0, the
exact per-lane sum (418 + B 7 + C 20)**.
· **gardenfly porosity — convergent, uncontested, adopted.** We added the identical param
(same name, same 0.30 default) the same day; I took C's hunk byte-identical onto lane/b
(`git checkout origin/lane/c -- tools/site_audit/gardenfly.js`) so integration merges
clean. My guard asserts (default flies as explicit 0.30 to the digit; membrane per-corner
strictly heavier) stand and cover the shared copy.
· **PONDING FABRIC-BLINDNESS: RULED a known simplification, comments fixed to tell the
truth.** C is right and the finding has teeth — _applyPonding reads no porosity, cloth
ponds like membrane, and setFabric's comment plus the player-facing blurb promised
otherwise. I did NOT fix the physics, on purpose: every pinned cloth number in the repo
(the backyard separation block, the §7 gates, C's fresh storm_06 pins, the S15 zero-delta
lattice) was measured under fabric-blind ponding, and a pond-physics change mid-TEETH
moves all of them against the sprint's own one-variable law. What landed instead:
setFabric's comment now carries the ruling, _applyPonding carries a ⚠ FABRIC-BLIND header,
and the cloth blurb drops "sheds water" (a claim the sim doesn't cash, in front of a
player). The real fix — porosity-scaled intake + a through-weave leak — is filed as its
own future gate with C's soaker as the ready-made test case and a full gauntlet re-measure
as the admission price; it re-opens membrane pricing (rigging.js's comment) the same day.
C: your "flag me the sprint you touch it" is the contract, countersigned.
· **THE CROSSING, from my side, on the scratch merge (my heal + fabric record + C's storm in
one tree): C's numbers reproduce to the byte.** fabric_bet probe, site_02 × soaker, funnel
ON: bare **17.5 DEAD** (byHail 66.9) · $75 rated-q1 ring **cloth 42.3 TATTERED vs membrane
96.5 FULL (Δ54.2)** · $60 shackle ring membrane 68.8 FULL · $20 carabiner INVERSION cloth
24.5 (2 lost) vs membrane 19.1 (3 lost). Identical to C's gate-3.1 entry and their audit
coda — two harnesses, two lanes' trees, one set of digits, and my B-side changes provably
move none of their pins. **The crossing is closed.** (C's coda already ran MY audit.html
over the soaker on their tree — bare 17.5 byte-identical there too.)
· **Gate 2.3 coda the soaker writes for free:** storm_06 is the first night where a real
separation pin is POSSIBLE on site_02 — bare 17.5 DEAD vs membrane 96.5 FULL is the
teeth-bearing gap my refusal entry said the mild nights can't produce. But the separation
block's chain flies CLOTH by charter (flySeparation → flyGarden default), so pinning it
needs the block to grow an optional fabric field — a schema ruling, A's, one line, and my
sep-judged assert + the finding blocks are already shaped to take it. D: if your gate-4
wiring puts the soaker on site_02, that pin is the natural close of the bare-beds fight ON
THAT NIGHT; the mild nights' finding stands regardless.
· **Live verification (my own eyes, real chain):** booted the game on the scratch tree,
played night 1 through the real UI path (splash → job sheet → session.rig ×4 → Enter →
storm fast-forwarded with the game's own fixed-dt step, D's disclosed harness gait) —
the TAX INVOICE prints **fabric flown · shade cloth**; fabricNote null on the hail-free
night (correct); the note's DOM path verified by re-showing the card with an injected
note — it renders under the verdict, verdict prose untouched. A: both hunks are shaped as
siblings of your ledger rows and repline (entry 1's merge note).
Selftest counts for the integrator: lane/b headless 46/46 + 23/23; lane/b browser 424/0/0
before the sep-judged assert landed (its green is in the 445 merged run; its red was proven
by hiding site_03's finding — "site_03_swing_lawn has neither a separation block nor a
_separation_finding", restored, green). Pushed lane/b.

View File

@ -719,6 +719,193 @@
"anchors": [],
"status": "PASS",
"problems": []
},
{
"name": "pergola_01",
"dims": [
3.2,
2.6,
2.71
],
"tris": 276,
"nodes": [
"beams",
"deck",
"pergola_01",
"pergola_anchor_01",
"pergola_anchor_02",
"posts",
"rafters"
],
"anchors": [
{
"node": "pergola_anchor_01",
"anchor_type": "pergola",
"rating_hint": 0.65,
"collateral": "pergola"
},
{
"node": "pergola_anchor_02",
"anchor_type": "pergola",
"rating_hint": 0.65,
"collateral": "pergola"
}
],
"status": "PASS",
"problems": []
},
{
"name": "pergola_01_wrecked",
"dims": [
3.3288,
2.6,
2.6648
],
"tris": 300,
"nodes": [
"beam_down",
"beams",
"deck",
"pergola_01_wrecked",
"posts",
"rafters_down"
],
"anchors": [],
"status": "PASS",
"problems": []
},
{
"name": "glasshouse_01",
"dims": [
2.645,
1.945,
2.3025
],
"tris": 356,
"nodes": [
"door",
"frame",
"glasshouse_01",
"panes",
"ridge_anchor"
],
"anchors": [
{
"node": "ridge_anchor",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
{
"name": "glasshouse_01_wrecked",
"dims": [
4.3356,
2.9387,
2.3025
],
"tris": 680,
"nodes": [
"door",
"frame",
"glasshouse_01_wrecked",
"panes_left",
"shards"
],
"anchors": [],
"status": "PASS",
"problems": []
},
{
"name": "pool_kit_01",
"dims": [
5.645,
4.5207,
1.22
],
"tris": 2780,
"nodes": [
"fence_post_01",
"fence_post_02",
"fence_post_03",
"fence_post_04",
"fence_post_05",
"fence_post_06",
"fence_post_07",
"fence_post_08",
"fence_post_09",
"fence_post_10",
"fence_post_11",
"fence_post_12",
"fence_post_13",
"fence_post_14",
"fence_rails",
"pool_gate",
"pool_kit_01",
"pool_shell",
"pool_water"
],
"anchors": [
{
"node": "fence_post_01",
"tie_off": false
},
{
"node": "fence_post_02",
"tie_off": false
},
{
"node": "fence_post_03",
"tie_off": false
},
{
"node": "fence_post_04",
"tie_off": false
},
{
"node": "fence_post_05",
"tie_off": false
},
{
"node": "fence_post_06",
"tie_off": false
},
{
"node": "fence_post_07",
"tie_off": false
},
{
"node": "fence_post_08",
"tie_off": false
},
{
"node": "fence_post_09",
"tie_off": false
},
{
"node": "fence_post_10",
"tie_off": false
},
{
"node": "fence_post_11",
"tie_off": false
},
{
"node": "fence_post_12",
"tie_off": false
},
{
"node": "fence_post_13",
"tie_off": false
},
{
"node": "fence_post_14",
"tie_off": false
}
],
"status": "PASS",
"problems": []
}
],
"debris": []

View File

@ -137,6 +137,11 @@ PAL = {
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
"bike_grip": "#3B4048", # grips and saddle
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
# a pool is the one water in the game that is
# supposed to look inviting
"deck_board": "#A8845C", # decking oil, a shade lighter than fence timber
}
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
@ -1522,6 +1527,544 @@ def build_swing_set_01_wrecked(name):
return root
# ---------------------------------------------------------------------------
# THE PERGOLA (SPRINT16 gate 5 — the honest middle rung, D-endorsed)
# ---------------------------------------------------------------------------
# One shape, stated once, used by the intact pergola AND the wreck — the
# _swing_frame precedent, kept on purpose: a wreck built from re-typed numbers
# drifts from its twin one edit at a time.
PERGOLA = dict(
DECK_W=3.2, # deck span, x
DECK_D=2.6, # deck depth, y
DECK_H=0.30, # two steps up — the deck IS the footing, that's the point
POST=0.09, # 90x90 treated pine
POST_H=2.20, # above the deck
INSET=0.18, # posts in from the deck edge
BEAM_H=0.14, # 140x45 beams along x on front/back post pairs
N_RAFTERS=7, # 70x35 rafters spanning y, the open roof
)
# What it costs when the timber cracks. Proposal — Lane A owns the number, the
# ladder it slots into is gnome 25 < gutter 90 < PERGOLA 120 < swing 140 <
# carport 180 (< glasshouse 320, below):
# · the failure is PARTIAL by design: the bolts hold, the timber breaks — a
# torn-off beam and a snapped post on a structure that stays standing. The
# bill is one beam, one post and the labour to re-bolt, not a rebuild;
# · under ~90 it undercuts the gutter, and a structural timber repair does
# not cost less than a run of guttering;
# · over ~140 it outbids the swing set, whose failure is the WHOLE frame on
# the grass — a worse day than a cracked beam, and the prices must agree
# with the wrecks the player actually sees.
PERGOLA_COLLATERAL = 120
def _pergola(name, root, deck_m, timber_m, dark_m, *, wrecked=False):
"""Build the pergola. Returns the two anchor points (Blender coords) so the
caller hangs anchors on the geometry's own numbers, not a second copy of
the arithmetic. Intact and wrecked share every dimension through PERGOLA.
HOW IT FAILS, and why that is the 0.65 story told twice: the posts are
BOLTED to the deck real M12s through the joists, and the bolts hold.
What gives is the timber and the deck's own stiffness: the loaded front
beam tears off its post tops, the front posts snap at half height above
the racking bolts, and the rafters still fixed at the back beam hinge
down like a lean-to until their front ends hit the deck. The back half
stands. A pergola fails PARTIALLY, which is exactly why its rating (0.65)
sits above the unbolted swing frame (0.45) and below concrete (1.00), and
why its bill (a beam and a post) sits below the swing's (a whole frame).
"""
P = PERGOLA
hw, hd = P["DECK_W"] / 2, P["DECK_D"] / 2
px, py = hw - P["INSET"], hd - P["INSET"] # post centres
top = P["DECK_H"] + P["POST_H"] # post top z
beam_z = top + P["BEAM_H"] / 2 # beam centre z
# --- the deck: joists + boards. Identical in the wreck — it was never the
# weak part; it is the footing the rating is ABOUT.
deck = [add_box(f"{name}_deck_slab", (P["DECK_W"], P["DECK_D"], 0.045),
(0, 0, P["DECK_H"] - 0.0225), deck_m)]
for i in range(4):
y = -hd + 0.12 + i * (P["DECK_D"] - 0.24) / 3
deck.append(add_box(f"{name}_joist_{i}", (P["DECK_W"] - 0.1, 0.045, P["DECK_H"] - 0.05),
(0, y, (P["DECK_H"] - 0.05) / 2), dark_m))
# Board grooves read as lines; three thin gaps of darker timber.
for i in range(5):
x = -hw + 0.3 + i * (P["DECK_W"] - 0.6) / 4
deck.append(add_box(f"{name}_board_gap_{i}", (0.015, P["DECK_D"] - 0.02, 0.008),
(x, 0, P["DECK_H"] + 0.004), dark_m))
join_group(deck, "deck", root)
# --- posts. Front pair (y, the yard side, where the anchors live) snaps
# in the wreck; back pair always stands.
posts = []
for sx in (-1, 1):
posts.append(add_box(f"{name}_post_b_{sx}", (P["POST"], P["POST"], P["POST_H"]),
(sx * px, py, P["DECK_H"] + P["POST_H"] / 2), timber_m))
if wrecked:
# Snapped at 55% height: the bolted stub stands plumb, the top
# half lies on the deck with its beam end, split face up.
stub = P["POST_H"] * 0.55
posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], stub),
(sx * px, -py, P["DECK_H"] + stub / 2), timber_m))
posts.append(add_box(f"{name}_post_top_{sx}",
(P["POST"], P["POST"], P["POST_H"] - stub),
(sx * (px - 0.35), -py + 0.42,
P["DECK_H"] + P["POST"] / 2), timber_m,
rot=(math.radians(88), 0, math.radians(20 * sx))))
else:
posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], P["POST_H"]),
(sx * px, -py, P["DECK_H"] + P["POST_H"] / 2), timber_m))
join_group(posts, "posts", root)
# --- beams. Back beam always up; front beam is the one that tears off.
beams = [add_box(f"{name}_beam_b", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
(0, py, beam_z), timber_m)]
if not wrecked:
beams.append(add_box(f"{name}_beam_f", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
(0, -py, beam_z), timber_m))
join_group(beams, "beams", root)
if wrecked:
# The torn beam, flat on the deck along the front edge, slightly askew.
# Rolled 90° about its own long axis so the 140 mm face lies on the
# boards (a beam at rest lies on its side; on edge it would balance).
join_group([add_box(f"{name}_beam_down", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
(0.18, -py + 0.15, P["DECK_H"] + 0.03),
timber_m, rot=(math.radians(90), 0, math.radians(6)))],
"beam_down", root)
# --- rafters: up in the intact roof; hinged down at the back beam in the
# wreck, front ends resting on the deck — the lean-to collapse.
rafters = []
for i in range(P["N_RAFTERS"]):
x = -hw + 0.35 + i * (P["DECK_W"] - 0.7) / (P["N_RAFTERS"] - 1)
if wrecked:
drop = math.atan2(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3)
L = math.hypot(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3)
rafters.append(add_box(f"{name}_rafter_{i}", (0.035, L, 0.07),
(x, py - L / 2 * math.cos(drop),
(top + P["BEAM_H"] + P["DECK_H"] + 0.05) / 2),
timber_m, rot=(-drop, 0, 0)))
else:
rafters.append(add_box(f"{name}_rafter_{i}", (0.035, P["DECK_D"] - 0.15, 0.07),
(x, 0, top + P["BEAM_H"] + 0.035), timber_m))
join_group(rafters, "rafters_down" if wrecked else "rafters", root)
# Anchor points: where the front beam meets the post tops — the bolted
# corner an author would genuinely strap to.
return [(-px, -py, top + P["BEAM_H"] * 0.6), (px, -py, top + P["BEAM_H"] * 0.6)]
def build_pergola_01(name):
"""A timber pergola on a bolted deck — the ladder's missing middle rung.
SPRINT16 gate 5, proposed in S15 and carried on D's playtester endorsement:
"the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65
bolted-to-deck mid-rung turns fascia-or-nothing into a real decision."
WHY 0.65, and why one rating for the whole type (the manifest rule my own
S15 entry proposed: new types ship ONE rating per type, so a node-less
JSON anchor can resolve it unambiguously): the post-beam corner is real
90x90 timber with real M12 bolts through a deck genuinely better than a
welded frame standing loose on grass (swing_frame 0.45), because the load
path reaches something fixed. And genuinely worse than 300 mm of concrete
(post 1.00), because what it reaches is a DECK: the deck flexes, the
timber cracks before the bolts let go, and 0.65 is that sentence as a
number. Typed `pergola` in its own enum word calling it a post would
promise concrete the deck cannot deliver (the swing_frame lesson, kept).
"""
root = add_empty(name)
deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8)
timber_m = get_material("Mat_Timber", PAL["timber"], 0.75)
dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8)
anchors = _pergola(name, root, deck_m, timber_m, dark_m, wrecked=False)
for i, ap in enumerate(anchors):
e = add_empty(f"pergola_anchor_{i + 1:02d}", ap, root, size=0.18)
e["anchor_type"] = "pergola"
e["rating_hint"] = 0.65
e["collateral"] = "pergola"
e["why"] = ("bolted post-beam corner: the bolts are real, the deck is "
"the footing — better than loose-on-grass, worse than "
"concrete, and the number argues exactly that")
stamp(root, name, "structure")
root["collateral_key"] = "pergola"
root["collateral_value"] = PERGOLA_COLLATERAL
root["collateral_label"] = "the pergola"
root["breakable"] = True
return root
def build_pergola_01_wrecked(name):
"""The pergola after a loaded corner won: front beam torn off, front posts
snapped above their bolts, rafters hinged down onto the deck like a
lean-to. The back half and the deck, and every bolt still standing,
because a bolted structure fails PARTIALLY, which is the whole 0.65 story.
Same origin, same PERGOLA numbers, one flag different, so intact and
wrecked cannot drift (the _swing_frame pattern)."""
root = add_empty(name)
deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8)
timber_m = get_material("Mat_Timber", PAL["timber"], 0.75)
dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8)
_pergola(name, root, deck_m, timber_m, dark_m, wrecked=True)
# No pergola_anchor_* survives — you cannot re-bolt to a snapped post from
# inside a storm, and an anchor that outlives its structure is the
# free-failure bug in a costume (fascia rule, third application).
stamp(root, name, "structure")
root["broken_variant_of"] = "pergola_01"
root["collateral_key"] = "pergola"
root["collateral_value"] = PERGOLA_COLLATERAL
root["collateral_label"] = "the pergola"
return root
# ---------------------------------------------------------------------------
# THE GLASSHOUSE (SPRINT16 gate 5 — the collateral apex)
# ---------------------------------------------------------------------------
GLASSHOUSE = dict(
W=2.6, # x — ridge runs along x
D=1.9, # y
WALL_H=1.70,
RIDGE_H=2.28,
BAR=0.045, # aluminium extrusion section
)
# The apex, priced as one. Proposal — Lane A owns the number:
# · it must top the carport (180) by a distance, or "apex" is a word: the
# carport is one sheet of Colorbond; this is ~14 panes of horticultural
# glass, and glass does not bend, it all goes at once. 320 is the panes,
# the reglaze labour, and a racked door that never closes right again;
# · it must stay under a six-night week's gross (~$540 in fees alone), so
# one catastrophe is a week worked for nothing, not an automatic BROKE —
# the carport ruling ("the worst thing on the bill and still a week you
# can dig out of"), scaled to the thing that costs a week;
# · unlike the bike (unpriced, nothing can break it) this ships priced ON
# THE SPEC'S OWN ORDER: arc 2's glasshouse yard is the event's home, and
# the price chain has to exist before that yard can be authored against
# it. Until an event lands, nothing bills it — the value is inert data,
# not a lie on an invoice. The tripwire is written in THREADS: the arc-2
# yard must wire the wreck event before this number reaches a bill.
GLASSHOUSE_COLLATERAL = 320
def _glasshouse_frame(name, root, alu):
"""The aluminium skeleton, shared by intact and wreck — the frame is the
part that SURVIVES (aluminium racks, glass shatters), which is why the
wreck stands at full height and reads as emptied, not felled."""
G = GLASSHOUSE
hw, hd, B = G["W"] / 2, G["D"] / 2, G["BAR"]
parts = []
# Corner + mid posts.
for x in (-hw, 0, hw):
for sy in (-1, 1):
parts.append(add_box(f"{name}_post_{x:.1f}_{sy}", (B, B, G["WALL_H"]),
(x, sy * hd, G["WALL_H"] / 2), alu))
# Eave beams (long sides) + end sills.
for sy in (-1, 1):
parts.append(add_box(f"{name}_eave_{sy}", (G["W"], B, B),
(0, sy * hd, G["WALL_H"]), alu))
for sx in (-1, 1):
parts.append(add_box(f"{name}_end_{sx}", (B, G["D"], B),
(sx * hw, 0, G["WALL_H"]), alu))
# Gable rafters up to the ridge.
for sy in (-1, 1):
p0 = (sx * hw, sy * hd, G["WALL_H"])
p1 = (sx * hw, 0, G["RIDGE_H"])
parts.append(add_tube_between(f"{name}_gable_{sx}_{sy}", p0, p1,
B * 0.45, alu, verts=6))
# The ridge — the straightest, most anchor-looking bar in any yard, which
# is exactly why it gets a named node and a denial (below, on the empty).
parts.append(add_box(f"{name}_ridge", (G["W"], B, B), (0, 0, G["RIDGE_H"]), alu))
join_group(parts, "frame", root)
# Door frame on the -x gable end.
join_group([
add_box(f"{name}_door_l", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, -0.36, 0.775), alu),
add_box(f"{name}_door_r", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, 0.36, 0.775), alu),
add_box(f"{name}_door_t", (B * 0.7, 0.76, B * 0.7), (-hw - 0.001, 0, 1.55), alu),
], "door", root)
def build_glasshouse_01(name):
"""A backyard glasshouse — the collateral apex, and NOT an anchor.
SPRINT16 gate 5. The palette's other structures are temptations because
they offer bad steel; this one is a temptation because it offers NO steel
while being ringed in members that look like it an aluminium ridge bar
at 2.28 m is the most tie-off-shaped object in the game. Every reading of
it must come back "don't": the ridge carries an explicit tie_off denial,
and NO node anywhere on either variant carries a rating_hint, which
e.test pins nothing about a glasshouse may adopt as an anchor, because
30 kg of glass in a storm is not a thing you add load to, it is a thing
you keep everything away from.
The panes read fragile at a glance because they are TRANSLUCENT the only
see-through walls in the palette and that read survives export as a
material fact (transparent + alpha), which e.test also pins: a repaint
that turns the glass opaque has silently deleted the tell.
"""
root = add_empty(name)
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30)
G = GLASSHOUSE
hw, hd = G["W"] / 2, G["D"] / 2
_glasshouse_frame(name, root, alu)
panes = []
# Wall panes: long sides + gable ends (leaving the door opening).
for sy in (-1, 1):
panes.append(add_box(f"{name}_wall_{sy}", (G["W"] - 0.06, 0.012, G["WALL_H"] - 0.08),
(0, sy * (hd - 0.012), G["WALL_H"] / 2), glass))
panes.append(add_box(f"{name}_endwall_e", (0.012, G["D"] - 0.06, G["WALL_H"] - 0.08),
(hw - 0.012, 0, G["WALL_H"] / 2), glass))
for sy in (-1, 1): # west end: two strips beside the door
panes.append(add_box(f"{name}_endwall_w_{sy}", (0.012, (G["D"] - 0.8) / 2 - 0.05, G["WALL_H"] - 0.08),
(-hw + 0.012, sy * (0.4 + (G["D"] - 0.8) / 4), G["WALL_H"] / 2), glass))
# Roof panes: two slopes from eave to ridge.
slope = math.atan2(G["RIDGE_H"] - G["WALL_H"], hd)
run = math.hypot(G["RIDGE_H"] - G["WALL_H"], hd)
for sy in (-1, 1):
panes.append(add_box(f"{name}_roof_{sy}", (G["W"] - 0.04, run - 0.03, 0.012),
(0, sy * hd / 2, (G["WALL_H"] + G["RIDGE_H"]) / 2 + 0.01),
glass, rot=(sy * slope, 0, 0)))
# Gable triangles, approximated as narrow strips under the rafters.
for sx in (-1, 1):
panes.append(add_box(f"{name}_gable_pane_{sx}", (0.012, G["D"] * 0.5, (G["RIDGE_H"] - G["WALL_H"]) * 0.8),
(sx * (hw - 0.012), 0, G["WALL_H"] + (G["RIDGE_H"] - G["WALL_H"]) * 0.42), glass))
join_group(panes, "panes", root)
# The temptation, denied ON the data. Same mechanism as the swing set's
# crossbar: the most anchor-looking member carries the explicit no.
not_a_tie_off(
add_empty("ridge_anchor", (0, 0, G["RIDGE_H"]), root, size=0.2),
"the ridge bar — the straightest steel-looking line in the yard",
"a 45 mm aluminium extrusion whose whole job is holding glass; load a "
"sail corner on it and you are not testing the bar, you are testing "
"fourteen panes at once")
stamp(root, name, "structure")
root["collateral_key"] = "glasshouse"
root["collateral_value"] = GLASSHOUSE_COLLATERAL
root["collateral_label"] = "the glasshouse"
root["fragile"] = True
root["pane_count"] = 14
return root
def build_glasshouse_01_wrecked(name):
"""The glasshouse after the storm found it. The frame is aluminium — it
stays up, near enough at full height but the glass is GONE: two cracked
panes still hanging, the rest a glitter field of shards on the ground,
thrown wider than the footprint ever was. A glasshouse doesn't fall, it
empties; a wreck that read as a collapsed building would be lying about
what glass does. Same frame helper as the intact one, so the two cannot
drift."""
rng = rng_for(name)
root = add_empty(name)
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30)
G = GLASSHOUSE
hw, hd = G["W"] / 2, G["D"] / 2
_glasshouse_frame(name, root, alu)
# Two survivors, one visibly askew in its frame.
join_group([
add_box(f"{name}_pane_left_a", (0.012, G["D"] * 0.4, G["WALL_H"] * 0.5),
(hw - 0.012, -0.3, G["WALL_H"] * 0.35), glass),
add_box(f"{name}_pane_left_b", (G["W"] * 0.3, 0.012, G["WALL_H"] * 0.45),
(0.4, hd - 0.012, G["WALL_H"] * 0.3), glass,
rot=(0, math.radians(8), 0)),
], "panes_left", root)
# The shard field. Deterministic scatter, thrown mostly downwind (+x, the
# storm convention every other wreck in the palette follows) and WIDER
# than the standing footprint — the measured claim e.test pins.
shards = []
for i in range(26):
a = rng.uniform(0, math.tau)
r = rng.uniform(0.2, 1.0)
x = math.cos(a) * (hw + r * 0.9) * (1.25 if math.cos(a) > 0 else 0.8)
y = math.sin(a) * (hd + r * 0.6)
s = rng.uniform(0.06, 0.22)
shards.append(add_box(f"{name}_shard_{i}", (s, s * rng.uniform(0.4, 0.9), 0.008),
(x, y, 0.006), glass,
rot=(0, 0, rng.uniform(0, math.pi))))
# A few inside on the beds' footprint too — the glass fell in as well as out.
for i in range(8):
x, y = rng.uniform(-hw * 0.8, hw * 0.8), rng.uniform(-hd * 0.7, hd * 0.7)
s = rng.uniform(0.05, 0.16)
shards.append(add_box(f"{name}_shard_in_{i}", (s, s * 0.7, 0.008),
(x, y, 0.006), glass, rot=(0, 0, rng.uniform(0, math.pi))))
join_group(shards, "shards", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "glasshouse_01"
root["collateral_key"] = "glasshouse"
root["collateral_value"] = GLASSHOUSE_COLLATERAL
root["collateral_label"] = "the glasshouse"
return root
# ---------------------------------------------------------------------------
# THE POOL KIT (SPRINT16 gate 5 — the compliant ring whose steel all says no)
# ---------------------------------------------------------------------------
POOL_KIT = dict(
RING_W=5.6, # fence ring, x
RING_D=4.2, # fence ring, y
FENCE_H=1.22, # AS 1926.1 minimum is 1.2 m — compliance is the flavour
POOL_W=3.6,
POOL_D=2.2,
COPING_H=0.18, # raised fibreglass lip; keeps every vertex above grade
POST=0.045, # 40 mm aluminium tube posts
)
def _pool_fence_posts():
"""Post centres round the ring, deterministic order. Module-level so the
builder and the ASSETS node list can never disagree about the count."""
P = POOL_KIT
hx, hy = P["RING_W"] / 2, P["RING_D"] / 2
pts = []
for x in (-hx, -hx / 2, 0, hx / 2, hx): # long sides, 5 each
for sy in (-1, 1):
pts.append((x, sy * hy))
for y in (-hy / 2, hy / 2): # short sides, 2 each
for sx in (-1, 1):
pts.append((sx * hx, y))
return pts # 14 posts
def build_pool_kit_01(name):
"""A backyard pool inside its compliant fence ring — the yard the palette
could not dress, and the puzzle is the fence.
SPRINT16 gate 5. Fourteen 40 mm aluminium posts stand in a perfect ring
at exactly the height a low tie line wants, core-mounted, evenly spaced
they LOOK like the most generous anchor field in the game. Every single
one carries an explicit `tie_off: false`, because silence is not neutral:
`adoptAnchor` reads `rating_hint ?? 1`, so an unrated node a site names
becomes the best steel in the game by omission (the S14 audit lesson, and
the reason the spec says "honest on every one"). A pool fence holds a
child OUT; it does not hold a sail DOWN it is 1.2 m of child-proofing
on core-drilled tube, and a loaded corner would have the whole panel out
of the ground before the shackle noticed.
That denial field is the level design: the pool yard's whole puzzle (arc
2) is that the legal tie-offs are all in the wrong places, and the ring
of honest nos in the middle of the yard is what makes it true.
UNPRICED, and that is a statement (the bike/jacaranda ruling, not the
glasshouse's spec order): nothing in the sim can bend a pool fence or
crack a coping yet, and billing an event the player cannot see is the lie
the invoice exists to kill. `priced: False` + the reason ride in extras;
price it the day debris can take a panel out.
"""
root = add_empty(name)
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
water = get_material("Mat_PoolWater", PAL["pool_water"], 0.08, opacity=0.88)
P = POOL_KIT
hx, hy = P["RING_W"] / 2, P["RING_D"] / 2
pw, pd = P["POOL_W"] / 2, P["POOL_D"] / 2
# --- the pool: raised coping ring + water just below the lip -----------
join_group([
add_box(f"{name}_coping_n", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]),
(0, pd + 0.075, P["COPING_H"] / 2), conc),
add_box(f"{name}_coping_s", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]),
(0, -pd - 0.075, P["COPING_H"] / 2), conc),
add_box(f"{name}_coping_e", (0.15, P["POOL_D"], P["COPING_H"]),
(pw + 0.075, 0, P["COPING_H"] / 2), conc),
add_box(f"{name}_coping_w", (0.15, P["POOL_D"], P["COPING_H"]),
(-pw - 0.075, 0, P["COPING_H"] / 2), conc),
], "pool_shell", root)
join_group([add_box(f"{name}_water", (P["POOL_W"], P["POOL_D"], 0.02),
(0, 0, P["COPING_H"] - 0.045), water)], "pool_water", root)
# --- the fence: rails + pickets, joined; POSTS as individual named nodes
# so a site (or the editor's node list, if it ever offers per-node
# anchors) meets the denial ON the node it would name. RULE 1's regex
# only guards *_anchor names; these are guarded by their own pin in
# e.test — same rule, wider net.
gate_span = (0.25, 1.15) # gate opening on the south run, x range
rails = []
for sy in (-1, 1):
for z in (0.12, P["FENCE_H"] - 0.03):
if sy < 0: # south run has the gate opening
rails.append(add_box(f"{name}_rail_s{z:.2f}_a", (gate_span[0] + hx, 0.04, 0.04),
((gate_span[0] - hx) / 2, -hy, z), alu))
rails.append(add_box(f"{name}_rail_s{z:.2f}_b", (hx - gate_span[1], 0.04, 0.04),
((gate_span[1] + hx) / 2, -hy, z), alu))
else:
rails.append(add_box(f"{name}_rail_n{z:.2f}", (P["RING_W"], 0.04, 0.04),
(0, hy, z), alu))
for sx in (-1, 1):
for z in (0.12, P["FENCE_H"] - 0.03):
rails.append(add_box(f"{name}_rail_{'e' if sx > 0 else 'w'}{z:.2f}",
(0.04, P["RING_D"], 0.04), (sx * hx, 0, z), alu))
# Pickets every ~0.1 m — the non-climbable read.
for sy in (-1, 1):
n = int(P["RING_W"] / 0.105)
for i in range(1, n):
x = -hx + i * P["RING_W"] / n
if sy < 0 and gate_span[0] < x < gate_span[1]:
continue
rails.append(add_box(f"{name}_pk_y{sy}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14),
(x, sy * hy, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu))
for sx in (-1, 1):
n = int(P["RING_D"] / 0.105)
for i in range(1, n):
y = -hy + i * P["RING_D"] / n
rails.append(add_box(f"{name}_pk_x{sx}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14),
(sx * hx, y, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu))
join_group(rails, "fence_rails", root)
# The posts — fourteen honest nos, each its own node.
for i, (x, y) in enumerate(_pool_fence_posts()):
post = join_group([add_cyl(f"{name}_postmesh_{i}", P["POST"] / 2, P["FENCE_H"],
(x, y, P["FENCE_H"] / 2), alu, verts=8)],
f"fence_post_{i + 1:02d}", root)
not_a_tie_off(
post, "pool fence post — compliance steel, not rigging steel",
"40 mm core-mounted aluminium tube holding a child-proof panel; "
"it resists a climbing toddler, not a loaded sail corner — the "
"ring LOOKS like fourteen perfect anchors and every one says no")
# The gate: self-closing, opens outward, latch high — the compliance
# tells, in geometry and in data.
gx0, gx1 = gate_span
gate = join_group([
add_box(f"{name}_gate_frame_l", (0.03, 0.03, P["FENCE_H"] - 0.1),
(gx0 + 0.03, -hy - 0.06, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu,
rot=(0, 0, math.radians(-14))),
add_box(f"{name}_gate_frame_r", (0.03, 0.03, P["FENCE_H"] - 0.1),
(gx1 - 0.15, -hy - 0.28, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu,
rot=(0, 0, math.radians(-14))),
add_box(f"{name}_gate_top", (gx1 - gx0 - 0.1, 0.03, 0.03),
((gx0 + gx1) / 2 - 0.06, -hy - 0.17, P["FENCE_H"] - 0.08), alu,
rot=(0, 0, math.radians(-14))),
*[add_box(f"{name}_gate_pk_{i}", (0.014, 0.014, P["FENCE_H"] - 0.22),
(gx0 + 0.1 + i * (gx1 - gx0 - 0.2) / 7,
-hy - 0.08 - i * 0.028, (P["FENCE_H"] - 0.22) / 2 + 0.08), alu)
for i in range(8)],
], "pool_gate", root)
gate["self_closing"] = True
gate["opens"] = "outward, ajar — someone left it swinging, which is the tell"
gate["latch_height_m"] = 1.5
stamp(root, name, "structure")
root["priced"] = False
root["unpriced_why"] = ("nothing in the sim can bend a pool fence or crack "
"a coping yet — billing an unseen event is the lie "
"the invoice exists to kill (the bike ruling); "
"price it when debris can take a panel out")
root["compliance"] = "AS 1926.1 flavour: 1.2 m, non-climbable, self-closing outward gate"
return root
def build_shed_01(name):
"""Colorbond garden shed, skillion roof. Spare hardware lives in here."""
root = add_empty(name)
@ -2969,6 +3512,38 @@ ASSETS = [
dict(name="fence_panel_snapped", fn=build_fence_panel_snapped,
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
nodes=["palings", "rails", "debris_palings"]),
# SPRINT16 gate 5 — the next temptations. The pergola is the ladder's
# honest middle rung (D-endorsed, S15 THREADS): TALLER than the swing set
# it out-rates, and its z floor is above the swing's 2.15 ceiling on
# purpose — if it ever measures swing-height, someone shrank the rung.
dict(name="pergola_01", fn=build_pergola_01,
dims=((3.1, 3.4), (2.4, 2.8), (2.55, 2.85)),
nodes=["deck", "posts", "beams", "rafters",
"pergola_anchor_01", "pergola_anchor_02"]),
# The wreck keeps the deck footprint but spills the torn beam past the
# edge (x floor ABOVE the intact deck's 3.2) and its roof line is the
# rafters hinged down — near intact height at the surviving back beam.
dict(name="pergola_01_wrecked", fn=build_pergola_01_wrecked,
dims=((3.2, 3.6), (2.4, 2.8), (2.55, 2.85)),
nodes=["deck", "posts", "beams", "beam_down", "rafters_down"]),
# The collateral apex. Narrow bands on z because the ridge IS the claim:
# a glasshouse that grew would stop reading as the small fragile thing.
dict(name="glasshouse_01", fn=build_glasshouse_01,
dims=((2.5, 2.8), (1.8, 2.1), (2.15, 2.40)),
nodes=["frame", "panes", "door", "ridge_anchor"]),
# Emptied, not felled: SAME height band as the intact one (the frame is
# aluminium and stands), but the shard field throws the plan dims WIDE —
# the x/y floors sit above the intact ceilings, which is the "it emptied
# outward" claim as a number.
dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked,
dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)),
nodes=["frame", "door", "panes_left", "shards"]),
# The compliance ring. z is pinned tight around 1.2 m because the height
# IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate.
dict(name="pool_kit_01", fn=build_pool_kit_01,
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
"fence_post_01", "fence_post_14"]),
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

172
tools/fabric_bet/probe.html Normal file
View File

@ -0,0 +1,172 @@
<!doctype html>
<!--
fabric_bet/probe.html — does the F key matter TONIGHT? [Lane C, SPRINT16 gate 3.1]
The measurement harness behind the pea-hail soaker. Every hail night before
storm_06 threw stones a 2 mm weave stops dead, so the fabric bet (SPRINT6)
never had a night where membrane was the right answer. This page flies the
SAME line on both fabrics through gardenfly.js — THE audit scoring chain
(windForSite → attach → skyfx → garden.js), dressed anchors, live sway — so
cloth-vs-membrane differ by exactly one variable: porosity.
Per line, per fabric: garden hp/state, byHail/byRain, corner peaks vs
effective ratings, marginal flags, cost. Plus the bare bed (the control) and
the storm's measured personality (stormStats — the numbers the forecast
card will sell).
Funnel state is printed in the header, per the discipline (three harnesses
in this repo's history flew site_02 funnel-off and believed it).
tools/fabric_bet/probe.html?site=backyard_01&storm=storm_06_soaker
-->
<meta charset="utf-8">
<title>fabric_bet probe</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:8px 0; }
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
th { color:#9ab; border-bottom:1px solid #333; }
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
</style>
<h1>fabric_bet — the same line, both fabrics, the real chain</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<div class="note" id="note"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { stormStats } from '../../web/world/js/weather.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { flyGarden, hardwareByName } from '../site_audit/gardenfly.js';
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'backyard_01';
const stormName = q.get('storm') || 'storm_06_soaker';
const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json();
// The candidate lines. COVER_QUAD is balance.test's canonical bed-covering
// quad; the others are cheaper/worse-covering controls so the bet's shape is
// visible across the price range, not just at one point.
const LINES = {
backyard_01: [
{ label: 'cover quad, all carabiners ($20)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
{ label: 'cover quad, all shackles ($60)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'wildnight $80 line (cara+rated+shackle+rated)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['carabiner', 'rated shackle', 'shackle', 'rated shackle'] },
{ label: 'separation-pin line p5+p1+p2+p3 ($75)', ids: ['p5', 'p1', 'p2', 'p3'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'] },
// The full-cover quads — unbuyable on the wildnight (6.9 kN, tears), which
// is exactly why the MILD night is where they come alive. p4+p5 span the
// bed north, p1+p2 south; the hull holds the whole bed.
{ label: 'FULL-COVER p4,p5,p1,p2 carabiners ($20)', ids: ['p4', 'p5', 'p1', 'p2'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
{ label: 'FULL-COVER p4,p5,p1,p2 shackles ($60)', ids: ['p4', 'p5', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'wide tree line t1,t2,p1,p2 shackles ($60)', ids: ['t1', 't2', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'house span h2,h3,p1,p2 shackles ($60)', ids: ['h2', 'h3', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
],
site_02_corner_block: [
{ label: 'S14 cheapest honest tr1,tr1b,q1,q3 ($40)', ids: ['tr1', 'tr1b', 'q1', 'q3'], hw: ['carabiner', 'carabiner', 'shackle', 'carabiner'] },
{ label: 'best clean tr1,tr1b,q1,q4 (shackles $60)', ids: ['tr1', 'tr1b', 'q1', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
// The bed ring — q1..q4 stand AROUND the bed, the yard's whole offer.
{ label: 'bed ring q1,q2,q3,q4 carabiners ($20)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
{ label: 'bed ring q1,q2,q3,q4 shackles ($60)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'bed ring, rated on the funnel corner q1 ($75)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'bed ring $75, winched tight (tension 1.2)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'], tension: 1.2 },
{ label: 'bed ring $60, winched tight (tension 1.2)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'], tension: 1.2 },
],
};
async function run() {
const site = await loadSite(siteName);
const scene = new THREE.Scene();
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
await world.dress();
const bed = world.gardenBed;
const anchors = world.anchors;
const venturi = site.wind?.venturi ?? [];
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const s = stormStats(stormDef);
const size = stormDef.hail?.size ?? 0;
const clothBlock = size ? hailBlockFor(size, 0.30) : 1;
el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n`
+ `FUNNEL: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain} — ON`).join(' · ') : 'none authored on this site'}\n`
+ `storm truth (stormStats): sustained ${s.sustained.toFixed(1)} m/s · gust peak ${s.gustPeak.toFixed(1)} m/s `
+ `(${(s.gustPeak * 3.6).toFixed(0)} km/h) · rain peak ${s.rainPeakMmPerHour.toFixed(0)} mm/hr · `
+ `hail ${s.hailSeconds.toFixed(1)} s-equiv @ size ${size} · change at ${s.changeAt ?? '—'}s\n`
+ `hailBlockFor(${size}, cloth 0.30) = ${(clothBlock * 100).toFixed(1)}% blocked · membrane = 100%`;
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const h of ['line', 'fabric', '$', 'garden', 'byHail', 'byRain', 'lost', 'marginal', 'peaks (kN vs eff)'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const rowFor = (label, g, fabric, cls) => {
const tr = tbl.insertRow();
const peaks = g.peaks.map((p) => `${p.id} ${(p.peakN / 1000).toFixed(2)}/${(p.effN / 1000).toFixed(2)}`).join(' ');
[label, fabric, `$${g.cost}`, `${g.hp.toFixed(1)} ${g.state.toUpperCase()}`,
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
g.marginal.join(',') || '—', peaks || '—']
.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c; if (i === 3) td.className = cls(g); });
};
const cls = (g) => (g.hp >= 66 ? 'ok' : g.hp >= 50 ? 'warn' : 'bad');
const results = { site: siteName, storm: stormName, bare, lines: [] };
for (const L of LINES[siteName] ?? []) {
const hw = L.hw.map(hardwareByName);
const tension = L.tension ?? 1.0;
const cloth = flyGarden({ anchors, bed, stormDef, siteDef: site, use, ids: L.ids, hw, tension, porosity: 0.30 });
const mem = flyGarden({ anchors, bed, stormDef, siteDef: site, use, ids: L.ids, hw, tension, porosity: 0 });
rowFor(L.label, cloth, 'cloth', cls);
rowFor('', mem, 'MEMBRANE', cls);
results.lines.push({ label: L.label, ids: L.ids, hw: L.hw, cloth, membrane: mem });
}
const tr = tbl.insertRow();
['BARE BED', '—', '$0', `${bare.hp.toFixed(1)} ${bare.state.toUpperCase()}`,
bare.byHail.toFixed(1), bare.byRain.toFixed(1), '4', '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl);
const best = results.lines.reduce((b, r) => (r.membrane.hp > (b?.membrane.hp ?? -1) ? r : b), null);
el('note').textContent = best
? `best membrane line: "${best.label}" → ${best.membrane.hp.toFixed(1)} vs same line on cloth ${best.cloth.hp.toFixed(1)} `
+ `(Δ ${(best.membrane.hp - best.cloth.hp).toFixed(1)} HP) · bare ${bare.hp.toFixed(1)}`
: 'no lines authored for this site — add them to LINES';
window.__fabricBet = results;
document.title = `fabric_bet — ${stormName}`;
}
run().catch((e) => {
el('note').className = 'bad';
el('note').textContent = `probe crashed: ${e.message}\n${e.stack || ''}`;
window.__fabricBet = { error: e.message };
document.title = 'fabric_bet — ERROR';
});
</script>

View File

@ -89,9 +89,17 @@ export function hardwareByName(name) {
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
* @param {number} [o.tension]
* @param {number} [o.porosity] fabric: 0.30 = shade cloth (the default every
* audit number to date was flown on unchanged),
* 0 = membrane. SPRINT16 gate 3.1 measures the
* fabric bet through THIS chain, same driver as
* every other garden number, so cloth-vs-membrane
* differ by exactly one variable. [Lane C addition,
* THREADS 2026-07-20 B: contest at integration if
* you want a different door.]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
*/
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) {
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = 0.30 }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
@ -99,8 +107,9 @@ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null,
if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (porosity 0.30): the fabric a competent player flies — same as sweep.js
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension);
// shade cloth (porosity 0.30) unless the caller is measuring the fabric
// bet — same default as sweep.js, so nothing pre-SPRINT16 moves.
rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension);
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
// bed and the number LOOKS plausible. Refuse to fly it.
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {

View File

@ -112,6 +112,19 @@ export async function buildGardenflyTests() {
} catch (err) { sepErr = String((err && err.stack) || err); }
}
// SPRINT16 gate 2.3 [B]: every shipped yard has been JUDGED on separation —
// either it pins a block (backyard_01) or it records a measured refusal
// (_separation_finding). Read all three here so the assert below can hold
// the invariant: neither is "unjudged", both at once is a stale finding.
let sepStates = null, sepStatesErr = null;
try {
sepStates = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
const sd = await loadSite(name);
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
}
} catch (err) { sepStatesErr = String((err && err.stack) || err); }
let venRun = null, venErr = null;
try {
const fly = (venturi) => flyGarden({
@ -122,6 +135,22 @@ export async function buildGardenflyTests() {
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
} catch (err) { venErr = String((err && err.stack) || err); }
// SPRINT16 [B]: flyGarden grew a `porosity` param (storm_06's membrane bet
// needs the audit engine to fly both fabrics). Two things must hold or every
// pinned number in THREADS quietly changes meaning: the DEFAULT is still the
// 0.30 shade cloth every pin was measured on, and membrane genuinely flies
// as a different fabric (double the pressure term → heavier corners).
let fabRun = null, fabErr = null;
try {
const flyFab = (porosity) => flyGarden({
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
siteDef: { wind: { venturi: SYN_FUNNEL } },
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
...(porosity === undefined ? {} : { porosity }),
});
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
} catch (err) { fabErr = String((err && err.stack) || err); }
return [
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
@ -168,6 +197,48 @@ export async function buildGardenflyTests() {
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
}
}],
['gate 2.3: every shipped yard is separation-JUDGED — pinned, or measured-and-refused', () => {
// SPRINT16's rule made durable: a yard may pin a separation block or it
// may carry a measured refusal (_separation_finding, the honest "this
// yard cannot separate at $80 under its storm" verdict with receipts) —
// but silence is not an option, and holding both means the finding went
// stale the day the pin landed and must be deleted (its own text says so).
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`);
for (const s of sepStates) {
assert(s.pinned || s.finding,
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
'run gardenfly, pin honestly or refuse honestly (SPRINT16 gate 2.3)');
assert(!(s.pinned && s.finding),
`${s.name} carries BOTH a pin and a refusal finding — the finding is stale, delete it in the pinning commit`);
}
return sepStates.map((s) => `${s.name}: ${s.pinned ? 'PINNED' : 'refused w/ finding'}`).join(' · ');
}],
['gardenfly: default fabric is still the pinned 0.30 cloth, and membrane flies heavier', () => {
if (fabErr) throw new Error(`fabric flight died: ${fabErr}`);
const { byDefault, cloth, membrane } = fabRun;
// omitting porosity === saying 0.30, to the digit — every pinned number
// in THREADS was measured on the default and must not move under it
assert(byDefault.hp === cloth.hp, `default flew hp ${byDefault.hp} vs explicit cloth ${cloth.hp} — the default fabric drifted`);
for (const d of byDefault.peaks) {
const c = cloth.peaks.find((p) => p.id === d.id);
assert(c && c.peakN === d.peakN, `corner ${d.id}: default peak ${d.peakN} != explicit-cloth ${c?.peakN} — the default fabric drifted`);
}
// porosity 0 must be a genuinely different fabric: the solid membrane
// catches the full pressure term, so every corner flies heavier
for (const m of membrane.peaks) {
const c = cloth.peaks.find((p) => p.id === m.id);
assert(m.peakN > c.peakN,
`corner ${m.id}: membrane peak ${m.peakN} N not above cloth ${c.peakN} N — the porosity param is not reaching the rig`);
}
// Measured 1.23x on this synthetic yard (browser, 2026-07-20) — less
// than the raw 1/0.7 pressure ratio because relative wind bleeds load as
// the heavier-loaded membrane accelerates. Floor at 1.1: the per-corner
// strictly-greater asserts above prove the wiring; this only guards
// against the difference collapsing to epsilon.
const up = membrane.peaks.reduce((s, m) => s + m.peakN / cloth.peaks.find((p) => p.id === m.id).peakN, 0) / 4;
assert(up > 1.1, `membrane only ${up.toFixed(2)}x cloth load on average — porosity is wired but not biting`);
}],
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
let threw = false;
try { hardwareByName('titanium dream'); } catch { threw = true; }

View File

@ -243,6 +243,10 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
separation, sepStormName, sepUnjudged,
// SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused
// one records why in its JSON; the card prints it so NONE PINNED reads as
// "measured and refused, here's the fight" rather than "nobody tried".
separationFinding: site._separation_finding ?? null,
collateral,
MARGIN: AUDIT.MARGIN,
};

View File

@ -9,6 +9,21 @@
"gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 },
"_separation_finding": [
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED. Measured",
"post-ruling (garden_probe, funnel ON, storm_03b_earlybuster, 2026-07-20): bare bed 82.6",
"FULL (hail 10.7 + rain 6.8 HP); best flown line 90.2; best gain +7.6 HP. The block's own",
"semantics need 'bare LOSES the night' (bareMustLoseBelow, win line 50) and a bare bed",
"WINS this night by 32 points — the early buster's pea hail simply cannot hurt an open bed",
"enough. Any pin writable today would be a fudge: either a bareMustLoseBelow above the win",
"line (the field name lying) or thresholds tuned until the slogan lands (the S13 fudge the",
"gate exists to kill). THE FINDING: this yard cannot separate held from bare at ANY price",
"under its shipped storm — that is a balance fact about the NIGHT's hail spectrum, not the",
"rig market. The fight is in THREADS [B] 2026-07-20; candidate levers are C's hail",
"spectrum, the win threshold, or per-night garden stakes — A rules. When a retune lands,",
"re-measure, pin like backyard_01's, and DELETE this block in the same commit."
],
"_design": [
"DESIGN.md wants the corner block anchor-poor — 'nowhere to tie off'. An empty yard is just a",
"smaller yard, so the trap is the interesting build: give it something that LOOKS like four free",

View File

@ -48,6 +48,16 @@
"The bare bed reads 83.7 FULL, which is the bare-beds-win pool item showing up on a",
"third yard - gate-4 material, not this yard's doing and not tuned here."
],
"_separation_finding": [
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED, same",
"verdict as site_02's. Measured post-ruling (garden_probe, funnel ON,",
"storm_03_southerly, 2026-07-20): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP); best",
"flown 89.8; best gain +6.1 HP — D's S14 card numbers reproduced. Bare WINS the",
"southerly by 34 points, so 'bareMustLoseBelow' cannot be written honestly at any",
"threshold. The pea-hail nights cannot hurt an open bed enough for a sail to be the",
"point of the night; that is the NIGHT's balance fact, filed in THREADS [B] 2026-07-20.",
"When the balance ruling lands, re-measure, pin like backyard_01's, delete this block."
],
"yard": {
"width": 24,
"depth": 16

View File

@ -0,0 +1,52 @@
{
"name": "Pea-Hail Soaker",
"blurb": "Warm, wet, and rattling — fine hail on and off all night under steady soaking rain. The wind never gets serious. The stones are small enough to matter.",
"rating": 3,
"seed": 61207,
"duration": 90,
"_why_comment": "SPRINT16 gate 3.1 — the first night the DEFAULT fabric is the wrong answer. Every prior hail night throws stones a 2 mm weave stops dead (storm_02 size 1.3, icenight 1.4 — cloth blocks 100%), so the F key never mattered. This storm's stones are size 0.45: UNDER the cloth aperture band (0.3..0.9 at porosity 0.30), so shade cloth blocks only 15.6% and leaks the rest onto the bed ALL NIGHT; membrane blocks everything. Wind is deliberately mild (sustained 9.2, gusts 15.2 m/s measured) so membrane's higher pressure load is affordable — the night's question is the fabric, per the one-variable rule. MEASURED on site_02 through gardenfly (fabric_bet probe, funnel ON, post-ruling tree, 2026-07-20): bare 17.5 DEAD · best cloth line at any price 42.3 TATTERED (a LOSS — 7.7 under the win line) · the SAME $75 ring on membrane 96.5 FULL · the $60 ring on membrane 68.8 FULL. Same line, one key, Δ54.2 HP. And membrane is NOT a free win: on $20 carabiners it reads 19.1 vs cloth's 24.5 — the doubled load tears cheap steel; bring shackles. This night was measured over BOTH shipped yards and it only works on the corner block: backyard_01's buyable geometry caps hail cover at ~31% (its full-cover quads pond-tear at 3.3+ kN even in this mild wind), so membrane cannot save what the sail cannot reach — the debut yard is site_02, and the pairing note is in THREADS for D.",
"baseCurve": [[0, 5.0], [15, 6.8], [30, 8.2], [50, 9.2], [70, 8.8], [90, 7.3]],
"gusts": {
"firstAt": 4,
"minGap": 6,
"maxGap": 11,
"powBase": 2.0,
"powRand": 3.0,
"powRamp": 2.0,
"downdraftOfTotal": 0.3
},
"_dir_comment": "A soft change at t=40 — later and gentler than the busters, swinging ~75 deg over 8 s. It keeps the series' one honest tell (the front wall rises in the quarter the new wind comes from) and it re-aims the yard: post-change the wind aligns with site_02's funnel axis, so q1 becomes the hot western corner (4.2 kN on cloth — the rated shackle's slot) while the pond load walks to the new downwind corner q2 (3.2-3.4 kN — the broom's slot). Two hot corners, one rated shackle in the budget: that is the night's rigging question once the F key is answered.",
"dirCurve": [[0, 0.6], [36, 0.7], [40, 0.4], [48, -0.6], [70, -0.7], [90, -0.65]],
"dirWander": { "amp": 0.2, "rate": 0.1 },
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 40, "type": "windchange", "telegraph": 6, "over": 8, "text": "the rain squares around" },
{ "t": 56, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 4, "text": "a tub floats off the path" },
{ "t": 66, "type": "lightning", "power": 0.4 }
],
"_rain_comment": "peakMmPerHour 44 and it rains from the first minute to the last — mean intensity ~0.75, so at decision 10's 40x the night delivers ~3 cm on a flat catch: enough that the bed ring (four 4.0 m posts, near-flat) ponds toward a real corner load on EITHER fabric (ponding reads geometry, not porosity — see THREADS), and the broom stays a tool. First drafts ran 55 then 48: at 55 the pond alone tore the $60 membrane ring's corners and the fabric lesson drowned in a rain lesson — 44 keeps exactly one pond-hot corner on the winning line.",
"rain": { "peakMmPerHour": 44, "curve": [[0, 0.15], [10, 0.45], [25, 0.75], [40, 0.9], [60, 1.0], [80, 0.85], [90, 0.7]] },
"_hail_comment": "size 0.45 = true pea hail, the storm's entire thesis (see _why_comment). Four authored bursts spread t=16..79 so the leak is 'all night', not a moment — and NO gust-synced hail on purpose: the stones arrive on the RAIN's schedule, not the wind's, which keeps the mild wind honest and the forecast legible. Weighted hail-seconds 14.9 measured — between the southerly's ~2 and the icenight's ~18 — and TUNED not maxed (the icenight's own lesson): first draft ran 17.2 and read bare 7.2 / best-cloth 33, a wall wearing a bet's clothes; at 14.9 the best cloth line loses by a clear 7.7 HP and the membrane ring wins FULL, which is the storm doing exactly one thing.",
"hail": {
"size": 0.45,
"bursts": [
{ "t": 16, "ramp": 2, "hold": 5, "fade": 3, "intensity": 0.35 },
{ "t": 33, "ramp": 2, "hold": 6, "fade": 3, "intensity": 0.43 },
{ "t": 49, "ramp": 2, "hold": 8, "fade": 3, "intensity": 0.52 },
{ "t": 70, "ramp": 2, "hold": 5, "fade": 4, "intensity": 0.39 }
]
},
"sky": { "darkness": 0.85, "cloudScroll": 0.07, "night": true }
}

View File

@ -82,6 +82,14 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
"house_yardside/fascia_anchor_03"
]
},
"pergola": {
"collateral": "pergola",
"rating_hint": 0.65,
"sources": [
"pergola_01/pergola_anchor_01",
"pergola_01/pergola_anchor_02"
]
},
"post": {
"rating_hint": 1.0,
"sources": [

View File

@ -81,9 +81,17 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
* concreted steel. This is a welded joint on a frame standing on grass: good
* steel, no footing, rating_hint 0.45. Same lesson as the carport, which was
* also nearly smuggled in as a post.
*
* `pergola` (SPRINT16 gate 5, E): a pergola's bolted post-beam corner the
* ladder's middle rung (0.65), D-endorsed in S15. Not a `post` for the same
* reason the swing frame isn't: the word promises the footing, and this one's
* footing is a DECK real bolts into real joists, so better than
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
* concrete would notice (1.00). One rating for the whole type, per the
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
*/
export const ANCHOR_TYPE = Object.freeze([
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame',
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
]);
// ---------------------------------------------------------------------------

View File

@ -316,6 +316,50 @@ export const PALETTE = [
})),
}),
},
// SPRINT16 gate 5 — E's three palette entries, applied verbatim by the
// integrator at merge (A delegated in THREADS; hunks filed by E with the
// _v1 suffixes present and load-bearing, per D's S14 find).
{
// SPRINT16 gate 5, E. The ladder's middle rung, D-endorsed in S15: bolted
// to a deck — better than loose-on-grass (0.45), worse than concrete
// (1.00). One type, one rating, so the manifest resolves it node-less.
kind: 'structure', label: 'Pergola (the middle rung)', model: 'pergola_01_v1',
requires: ['pergola'],
hint: 'two bolted corners at 0.65 — the honest step between swing frame and post',
make: (id, x, z) => ({
id, model: 'pergola_01_v1', wreckedModel: 'pergola_01_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'pergola',
collateralValue: 120, collateralLabel: 'the pergola',
anchors: [1, 2].map((i) => ({
id: `${id}_a${i}`, node: `pergola_anchor_0${i}`, type: 'pergola', work: 'cloth',
})),
}),
},
{
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
// EXPLICIT so the second one placed still bills (the carport lesson).
kind: 'structure', label: 'Glasshouse (the apex, $320)', model: 'glasshouse_01_v1',
hint: 'no anchors at all; $320 of glass — the thing you keep the whole rig away from',
make: (id, x, z) => ({
id, model: 'glasshouse_01_v1', wreckedModel: 'glasshouse_01_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'glasshouse',
collateralValue: 320, collateralLabel: 'the glasshouse',
anchors: [],
}),
},
{
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
// unpriced BY RULING (the bike rule — price it when debris can bend it).
kind: 'structure', label: 'Pool + fence ring', model: 'pool_kit_01_v1',
hint: 'fourteen perfect-looking posts, every one an honest no — the legal tie-offs are elsewhere',
make: (id, x, z) => ({
id, model: 'pool_kit_01_v1', x, z, rotYDeg: 0, solid: true,
anchors: [],
}),
},
{
kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true,
hint: 'three fascia anchors at rating_hint 0.35 — "the fascia board is a lie"',

View File

@ -312,6 +312,12 @@ function render(out, s, ms) {
} else if (s.sepUnjudged) {
const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn');
cs.append(el('div', 'ed-note', s.sepUnjudged));
} else if (s.separationFinding) {
// SPRINT16 gate 2.3 [B]: measured, and honestly REFUSED — different fact
// from "nobody tried", and the card must not flatten the two together.
const cs = card(out, 'SEPARATION TARGET — PIN REFUSED (measured)', 'ed-warn');
cs.append(el('div', 'ed-note',
Array.isArray(s.separationFinding) ? s.separationFinding.join(' ') : String(s.separationFinding)));
} else {
const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn');
cs.append(el('div', 'ed-note',

View File

@ -59,6 +59,7 @@ export const STORM_KEYS = [
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_06_soaker',
];
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };

View File

@ -16,6 +16,10 @@
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines, leadFor } from './weather.js';
// SPRINT16 — the end card compares the final rep against neutral. A constant,
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
// this import costs nothing it didn't already cost main.js.
import { REP } from './week.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
@ -47,6 +51,14 @@ const ABN = 'ABN 12 345 678 901';
*/
const docketNo = (night) => 1040 + night;
/**
* SPRINT16 gate 1 the letterhead's number, formatted once. Stars, one
* decimal (week.js REP moves in half-steps, so the decimal is always honest).
* It prints under the ABN on job sheet AND invoice E's letterhead contract:
* the business is the constant, and this is the one number the business has.
*/
const repFmt = (r) => `${Number(r).toFixed(1)}`;
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
@ -123,6 +135,11 @@ 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; }
/* SPRINT16 gate 2.2 [B] the fabric sentence under the verdict, quieter than
the verdict itself: it is a footnote about the bet, not a second ruling.
Sibling of .verdict (same pattern as the repline), so the verdict div and
its a.test-pinned prose stay byte-untouched. */
#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; }
/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
@ -169,6 +186,15 @@ const CSS = `
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
/* SPRINT16 the reputation line, under the ABN (the spec's words). Slightly
brighter than the docket number because it is the one thing in this corner
that MOVES, and a number that moves is a number the eye should find. */
#hud-card .letterhead .docket .rep { font-size: 10px; color: #8ba0ad; letter-spacing: .08em; margin-top: 2px; }
/* SPRINT16 the invoice speaks rep: one quiet line under the verdict, where
the movement and its reason live. Not part of the ledger because it is not
money it is the thing that PRICES tomorrow's money. */
#hud-card .repline { margin: 8px 0 0; font-size: 11px; color: #8ba0ad; letter-spacing: .04em; }
/* --- bill-to / job-for block -------------------------------------------
Deliberately the same component on both documents. On the job sheet it's
@ -753,6 +779,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<div class="docket">
<div class="kind">JOB SHEET</div>
<div class="no">JOB ${docketNo(wk.night)} · ${ABN}</div>
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
</div>
</div>
<div class="billto">
@ -774,11 +801,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// E's preview copy read "bed above 80% at dawn" and the mechanic pays
// linearly, and a quote that misstates its own terms is a lie on paper.
const q = wk.quote;
// SPRINT16 — the multiplier is on the sheet the night it applies, and
// only then: ×1.00 every night would teach that the number is decoration.
// The warranty lines are the other half of gate 1's paper: booked by last
// dawn's broken corners, worded as the spec words them ("no charge to the
// client" IS the sentence — unpaid labour is the point), priced at the
// parts' shop price, and already inside q.total so the sheet's conclusion
// is the deducted number the invoice will pay. Quote==settle is week.js's
// construction; this card just refuses to hide the deduction.
const feeTag = q && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : '';
const warrantyRows = (q?.warranty ?? []).map((it) => `
<div class="row bad"><span>warranty ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>
<div class="cond">your corner, down at dawn${it.addr ? `${it.addr}` : ''} · parts at shop price, labour on you</div>`).join('');
const schedule = q ? `
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up</div>
<div class="row headline"><span>callout fee${feeTag}</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up${q.feeMult !== 1 ? ' — priced on the letterhead number' : ''}</div>
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
<div class="cond">${q.beyondSaving
// SPRINT14 — B's pool item. On a gardenBeyondSaving night the bed
@ -793,7 +832,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
? 'scales with the bed at dawn — and tonight the ice takes it whatever you rig'
: "paid on what's left of the bed at dawn"}</div>
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
<div class="cond">nothing of theirs broken</div>
<div class="cond">nothing of theirs broken</div>${warrantyRows}
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
</div>` : '';
@ -831,6 +870,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
${siteLine}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${/* SPRINT16 C's gate 3.3 seam (THREADS [A] seam contract): when
forecastLines exposes ready-to-print stone-size and rain-rate
lines, the sheet prints them verbatim; until then this renders
nothing. The bet (cloth vs membrane) is argued HERE or nowhere
the forecast is the only place the player chooses from. */''}
${f.stones ? `<div class="stat">${f.stones}</div>` : ''}
${f.rainRate ? `<div class="stat">${f.rainRate}</div>` : ''}
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
${tomorrow}
${schedule}
@ -887,6 +933,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
const nights = s.night;
// SPRINT16 — the end of the week speaks rep. A week that ends
// broke-but-clean and a week that ends rich-with-warranties are
// different endings (the gate's own words), and the difference is the
// number the next client sees — so the last thing the game says carries
// it. Keyed on where the letterhead number LANDED relative to neutral
// (REP.START), crossed with whether you're still trading; the row above
// it shows the warranty count so "rich" has its receipts.
const repKick = s.rep == null ? '' : s.rep > REP.START
? (won ? ' The name on the letterhead is worth more than it was on Monday.'
: ' The book says broke. The number says the phone rings again.')
: s.rep < REP.START
? (won ? ' The money came in. The mornings after are what gets remembered.'
: ' And word of the mornings after got there first.')
: '';
card.innerHTML = `<div class="card endcard ${won ? 'win' : 'lose'}"
style="background-image:url(./models/textures/${art})">
<div class="endtext">
@ -895,7 +956,9 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<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>
${s.rep != null ? `<div class="row"><span>reputation</span><b>${repFmt(s.rep)}</b></div>` : ''}
${s.warrantiesToDate ? `<div class="row"><span>warranty jobs booked</span><b>${s.warrantiesToDate}</b></div>` : ''}
<div class="kicker">${kick}${repKick}</div>
<button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
</div>
</div>`;
@ -917,6 +980,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['what got through', r.hailBlocked],
// SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
// choice the player made at the F key, printed on the same paper as
// what it did or didn't stop. Unconditional — the record is the point.
...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
@ -948,9 +1015,16 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// because "what this night was worth" and "where that leaves you" are two
// different facts.
const due = w ? w.pay - w.spent : 0;
// SPRINT16 — the invoice answers the sheet's warranty lines word for
// word (same items, same derivation in week.js), and the fee carries the
// same standing multiplier the sheet quoted. The ledger must keep
// summing exactly as shown — a.test pins pay to these rows.
const invWarrantyRows = w ? (w.warranty ?? []).map((it) => `
<div class="row bad"><span>warranty ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>$${it.cost}</b></div>`).join('') : '';
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed${
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
// night this row is always small, and next to "callout fee — night
@ -963,7 +1037,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
}</span><b>+$${w.bonus}</b></div>
${cleanRow}
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows}
${collateralRows}${invWarrantyRows}
<div class="row bad"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row due ${due < 0 ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${due < 0 ? '' : '+'}$${Math.abs(due)}</b></div>
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div>
@ -987,6 +1061,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<div class="docket">
<div class="kind">TAX INVOICE</div>
<div class="no">INV ${docketNo(w.night)} · ${ABN}</div>
${w.rep != null ? `<div class="rep">standing ${repFmt(w.rep)}</div>` : ''}
</div>
</div>
<div class="billto">
@ -1001,6 +1076,19 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
${rows}
${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
${/* SPRINT16 the verdict speaks rep: the movement AND its reason, so
the number on the letterhead is never a number that changed for no
stated cause. week.js writes the reasons (including the flat
"broke, but repaired by dawn" case) this line just reads them. */''}
${w && w.rep != null ? `<div class="repline">reputation ${repFmt(w.repBefore)}${repFmt(w.rep)}${
w.repReasons?.length ? `${w.repReasons.join(' · ')}` : ''}</div>` : ''}
${/* SPRINT16 gate 2.2 [B] when the fabric bet provably mattered the
verdict page says so, one quiet line under the verdict (same
pattern as A's repline: the verdict div itself stays byte-
untouched). fabricNoteFor's thresholds decide; null = silent.
(Keep-both at integration, per B's own merge notes the two
lines are siblings by design.) */''}
${r.fabricNote ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}
<button class="go">${next}</button>
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
</div>`;

View File

@ -23,7 +23,7 @@ import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createRiggingUI, fabricNoteFor } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
import { createGarden } from './garden.js';
@ -272,6 +272,52 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
}
/**
* SPRINT16 gate 1 the work history, per corner at dawn: what the ledger
* keeps. The night already computed every field; this is the one place they're
* assembled, so week.js can store them (settlement.rig) and tomorrow's sheet
* can book warranty off them. Shapes per the THREADS seam contract:
*
* broke it let go at least once tonight. Read off the EVENT TALLY,
* not the corner flag repairCorner() clears `broken`, so by
* dawn the flag has forgotten every break that was fixed.
* repaired broke, and was standing again at dawn (D's hold-E). NOT a
* warranty item, by spec: fixing it mid-storm is the job.
* brokenAtDawn still down when the storm ended books tomorrow's warranty
* line and takes tonight's rep hit. Riding it out broken is
* the thing the ledger exists to price.
*
* `repaired` and `brokenAtDawn` partition `broke` a corner that broke twice
* and was fixed once is brokenAtDawn, not repaired. hw/hwCost are what was on
* the corner AT DAWN (a spare-repaired corner reads the spare, honestly that
* is the steel the client's yard is wearing tomorrow). effRating is
* rating × ratingHint, the number sail.js actually fails on the same truth
* the HUD bars and the verdict print, third surface, one formula.
*
* A value, exported, for the Enter-guard reason: scoreRun lives inside boot()
* behind a WebGL context where no assert can reach it, and the ledger's
* partition semantics are exactly the kind of rule that would otherwise only
* ever be "tested" by a green suite that never ran it.
*
* @param {Array} corners rig.corners at dawn
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
*/
export function rigRecordFor(corners, tally = new Map()) {
return corners.map((c) => {
const tal = tally.get(c.anchorId);
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
return {
anchorId: c.anchorId,
hw: c.hw.name,
hwCost: c.hw.cost,
effRating: Math.round(c.hw.rating * (c.anchor?.ratingHint ?? 1)),
broke,
repaired: broke && !c.broken,
brokenAtDawn: !!c.broken,
};
});
}
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
@ -839,7 +885,22 @@ export async function boot(opts = {}) {
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving });
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
// landed in the fresh-eyes review; this is the paperwork half: the invoice
// carries the fabric every night (r.fabric → hud's rows), and the verdict
// gets ONE extra sentence when the bet provably mattered (fabricNoteFor —
// pure, thresholded, silent when a sentence would be a guess). The
// FORECAST half — arguing the bet before you rig — is C's gate 3.3.
const fabric = rigging.session.fabric;
const fabricNote = fabricNoteFor({
fabric,
hailSize: defs[week.stormKey]?.hail?.size ?? 0,
dmg, pondPeak,
});
return {
fabric: { id: fabric.id, name: fabric.name },
fabricNote,
hp,
cornersLost: lost.length,
cornersTotal: rig.corners.length || 4,
@ -868,6 +929,8 @@ export async function boot(opts = {}) {
verdict,
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
verdictMode: mode,
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
rigRecord: rigRecordFor(rig.corners, nightTally),
};
}
@ -940,6 +1003,10 @@ export async function boot(opts = {}) {
{
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
// SPRINT16 — the letterhead's number. The quote carries it too
// (quote.rep), but the sheet renders a letterhead even on nights whose
// quote degrades away, so it rides the week block explicitly.
rep: week.rep,
},
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
@ -960,6 +1027,22 @@ export async function boot(opts = {}) {
if (!e.reason) pondDumped += e.kg;
});
// SPRINT16 gate 1 — the night's break/repair tally, per anchor. The ledger's
// `repaired` bit cannot be read off the corners alone: repairCorner() clears
// `broken`, so at dawn a fixed corner looks identical to one that never went.
// Same pattern as pondDumped above (subscribe once, reset at storm start) and
// for the same reason: the Emitter survives re-rigs, and pre-storm events —
// a divergence break on the calm day, say — are not the night's story.
/** @type {Map<string, {broke:number, repaired:number}>} */
const nightTally = new Map();
const tallyFor = (id) => {
let t = nightTally.get(id);
if (!t) { t = { broke: 0, repaired: 0 }; nightTally.set(id, t); }
return t;
};
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
// Prep and forecast happen on the calm day; the storm you picked only
@ -980,7 +1063,7 @@ export async function boot(opts = {}) {
if (paused) { paused = false; hud.setPaused(false); }
if (muted) setMuted(true);
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
if (to === 'forecast') {
hud.setDawn(false);

View File

@ -14,6 +14,7 @@
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
import { hailBlockFor } from './weather.core.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
@ -45,11 +46,68 @@ export const DEFAULT_TENSION = 1.0;
* 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' },
// Blurb honesty (SPRINT16): "sheds water" deleted — C measured that ponding
// is fabric-blind (_applyPonding never reads porosity; both cloths pond
// identically), and a player-facing claim the sim doesn't cash is the worst
// kind. Ruled a known simplification in sail.js setFabric's comment.
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, 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];
/**
* SPRINT16 gate 2.2 the verdict's fabric sentence, ONLY when the bet
* provably mattered. The invoice records the fabric every night (the record is
* unconditional); this is the extra line the aftermath gets to say about it,
* and it must never guess: dmg.hail can't be split into through-the-weave vs
* fell-beside-the-sail after the fact, so the note speaks only when the
* MODEL's own numbers make the claim safe.
*
* · Cloth on a night whose stones pass the weave (hailBlockFor < 1 C's
* 2 mm-weave ruling) with real hail damage: name the leak. That's the
* spec's own case, "a membrane night survived in cloth should say what the
* leak cost". The phrasing claims what is true that much hail REACHED
* the bed and the membrane stops those stones not that membrane would
* have saved exactly that many HP (some of it may have fallen past the
* sail's edge, which no fabric fixes).
* · Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name
* the trade the pond IS the membrane's price, and the verdict prose for
* ponded/broomed nights doesn't say which cloth bought it.
* · Everything else: null. Big-hail nights (both fabrics block 100%), dry
* nights, clean wins the row on the invoice is the whole record.
*
* Pure, headless-testable; thresholds are data on the function so the asserts
* and the wiring read the same numbers.
*
* @param {object} o
* @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric)
* @param {number} [o.hailSize] storm def's hail.size (1.0 1.5 cm stone); 0/absent = no hail tonight
* @param {object} [o.dmg] garden.damage {hail, rain}, HP
* @param {number} [o.pondPeak] peak kg of water the sail held tonight
* @returns {string|null} one sentence for the aftermath card, or null
*/
export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) {
if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null;
if (fabric.porosity > 0) {
const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1;
if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) {
return `Flown in ${fabric.name} — stones this fine rattle through the weave, and `
+ `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`;
}
return null;
}
if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) {
return `Flown in ${fabric.name} — it stopped the stones and the rain, and held `
+ `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`;
}
return null;
}
/** The leak has to have cost real HP before the verdict brings it up. */
fabricNoteFor.LEAK_MATTERS_HP = 5;
/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number
* the note and the verdict must agree on when a pond is worth a sentence. */
fabricNoteFor.POND_MATTERS_KG = 80;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true };

View File

@ -6,7 +6,7 @@
* js/tests/b.test.js) and node.
*/
import { RiggingSession, FABRIC } from './rigging.js';
import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
@ -359,6 +359,39 @@ test('fabric is a bet the F key can actually place (SPRINT15 — the dead mechan
return 'cloth -> membrane -> cloth, $0 both ways, hessian refused';
});
// --- SPRINT16 gate 2.2: the fabric on the record ---------------------------
// The invoice row is unconditional wiring (main.js/hud.js, verified live);
// what this pins is the VERDICT's fabric sentence — fabricNoteFor speaks only
// when the bet provably mattered, and stays silent everywhere a sentence
// would be a guess. Storm hail sizes are the shipped ones: southerly pea hail
// 0.7 (cloth blocks 74% — hailBlockFor's measured number), wild night 1.3 and
// ice night 1.4 (both fabrics block 100%).
test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => {
const [CLOTH, MEMBRANE] = FABRIC;
// the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP
const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } });
assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`);
assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`);
// big stones cannot pass the weave — cloth blocked 100%, hail damage came
// from where the sail wasn't, and blaming the weave for it would be a lie
assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null,
'cloth blamed for big-stone hail — hailBlockFor(1.3, 0.3) is 1.0, nothing leaked');
// a leak that cost under the threshold is not worth a verdict sentence
assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null,
'a trivial leak got a sentence — the threshold is not being read');
// membrane's price is the pond, and only a pond the verdict itself would name
const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 });
assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`);
assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null,
'membrane got a sentence over a pond the verdict would not even mention');
// no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw
assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed');
assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent');
assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw');
return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`;
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -529,6 +529,11 @@ export class SailRig {
/**
* Rain lands, runs downhill, and pools where it can't get out. Called after
* the wind pass (which fills _nodeFlat / _nodeArea).
*
* FABRIC-BLIND, on purpose and on the record (SPRINT16, C's finding):
* nothing below reads `this.porosity`, so an open weave ponds exactly like a
* membrane. Ruled a known simplification see setFabric's comment for the
* ruling and the re-measure bill a real through-weave leak would incur.
* @param {number} mmPerHour wind.rainMmPerHour(t) REAL-world rate, Lane C's data
*/
_applyPonding(mmPerHour, dt) {
@ -921,6 +926,7 @@ export class SailRig {
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
*/
_checkFailure(dt) {
let diverged = false;
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
@ -938,6 +944,7 @@ export class SailRig {
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' });
diverged = true;
continue;
}
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
@ -957,9 +964,94 @@ export class SailRig {
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
}
}
// SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable
// (the fresh-eyes review's fix); this makes it survivable to LOOK at. The
// freed corner otherwise keeps integrating from the NaN the solver left in
// the spring network, and the cloth a player sees after the bang is a
// shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break —
// an honest overload break has finite state and must not be touched.
if (diverged) this._healNonFinite();
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}
/**
* Reset every non-finite node from its nearest finite neighbour (grid rings,
* straight neighbours before diagonals _nbr's own order), or from the
* corners' anchors when the whole cloth is poisoned. `prev` is matched to
* `pos` on every healed node so verlet reads zero velocity out of the heal
* instead of inventing a launch. Non-finite WATER is zeroed too: a NaN pond
* re-poisons the healed positions on the very next substep through the
* weight term (F += GRAVITY * w), so healing positions alone heals nothing.
*
* Deterministic (fixed iteration order, no randomness, no clock), and pure
* repair: a rig with fully finite state is untouched byte-for-byte the
* negative control in sail.selftest.js asserts a clean storm never emits
* 'heal'. Emits 'heal' {nodes, t} so a log can count what a bang cost.
*
* @returns {number} nodes healed
*/
_healNonFinite() {
const pos = this.pos, prev = this.prev, w = this.water;
const n = this.invMass.length;
// A NaN pond is poison whether or not its node's position survived. On
// the divergence path today this is a SECOND wire — the break's
// dumpPond('corner blew') already fill(0)s the whole pond — but the heal's
// own contract must not depend on the dump's side effect: if divergence
// ever stops dumping (a "the cloth healed, keep the water" retune), the
// NaN pond would corrupt pondMass/centroid/broom forever. Unit-asserted
// directly in sail.selftest.js for exactly that reason.
for (let i = 0; i < n; i++) if (!Number.isFinite(w[i])) w[i] = 0;
const sick = new Uint8Array(n); // 1 = position needs a donor
let sickCount = 0, anyFinite = false, healed = 0;
for (let i = 0; i < n; i++) {
const x = i * 3;
if (Number.isFinite(pos[x]) && Number.isFinite(pos[x + 1]) && Number.isFinite(pos[x + 2])) {
anyFinite = true;
// finite position, poisoned velocity: match prev, keep the position
if (!Number.isFinite(prev[x]) || !Number.isFinite(prev[x + 1]) || !Number.isFinite(prev[x + 2])) {
prev[x] = pos[x]; prev[x + 1] = pos[x + 1]; prev[x + 2] = pos[x + 2];
healed++;
}
} else { sick[i] = 1; sickCount++; }
}
if (sickCount) {
// Whole cloth gone: re-seed the corners from their anchors — the one
// world position a rig always knows — then flood from there.
if (!anyFinite) {
for (let k = 0; k < 4; k++) {
const ci = this.cornerIdx[k], x = ci * 3;
const p = this._anchorPos(this.corners[k].anchor, this.t);
pos[x] = prev[x] = p.x; pos[x + 1] = prev[x + 1] = p.y; pos[x + 2] = prev[x + 2] = p.z;
if (sick[ci]) { sick[ci] = 0; sickCount--; healed++; }
}
}
// Ring-by-ring flood: each pass, a sick node bordering the finite region
// copies that neighbour and joins it, so positions march inward from the
// nearest healthy cloth. Terminates: the grid is connected, so every
// pass with sick nodes left heals at least one.
let changed = true;
while (sickCount && changed) {
changed = false;
for (let i = 0; i < n; i++) {
if (!sick[i]) continue;
for (let k = 0; k < 8; k++) {
const m = this._nbr[i * 8 + k];
if (m < 0 || sick[m]) continue;
const x = i * 3, mx = m * 3;
pos[x] = prev[x] = pos[mx];
pos[x + 1] = prev[x + 1] = pos[mx + 1];
pos[x + 2] = prev[x + 2] = pos[mx + 2];
sick[i] = 0; sickCount--; healed++; changed = true;
break;
}
}
}
}
if (healed) this.events.emit('heal', { type: 'heal', nodes: healed, t: this.t });
return healed;
}
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
// D landed first and duck-typed these against the rig, so B conforms to D's
// spelling rather than the other way round. Thin aliases on purpose: the
@ -1018,10 +1110,22 @@ export class SailRig {
}
/**
* 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.
* What the sail is made of. Porosity scales the wind pressure term and, as
* of SPRINT16, that is ALL it scales. The previous version of this comment
* claimed an open weave "sheds the water it can't hold so a porous cloth
* also ponds far less", and C's gate-3 measurement caught the cheque the sim
* doesn't cash: _applyPonding never reads porosity, so cloth and membrane
* pond IDENTICALLY (the $75 soaker ring's q2 gap, 3.24 vs 3.32 kN, is wind
* term only). RULED a known simplification rather than silently fixed
* ([B] THREADS 2026-07-20): every pinned cloth number in the repo the
* backyard separation block, the §7 gates, C's storm_06 pins, the S15
* zero-delta lattice was measured under fabric-blind ponding, and a pond-
* physics change mid-TEETH would move all of them against the sprint's own
* one-variable law. The fix (porosity-scaled intake + through-weave leak)
* is real, wants its own gate with a full gauntlet re-measure, and C's
* soaker is its ready-made test case; rigging.js's membrane-pricing comment
* is the same conversation. Set BEFORE attach(); RiggingSession.commit()
* does that.
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
*/
setFabric(f) {

View File

@ -219,6 +219,114 @@ test('divergence BREAKS on the production path — a NaN corner cannot hold fore
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
});
// --- SPRINT16 gate 2.1: THE HEAL -------------------------------------------
// The break above (fresh-eyes review) made divergence detectable; these pin
// that it is also SURVIVABLE TO LOOK AT. Before the heal, the freed corner
// integrated from the NaN the solver left in the spring network and the cloth
// after a bang was a shredded mess, not a cloth. The three tests are one
// mechanism, three poison shapes: position NaN, velocity (prev) NaN via the
// pond weight, and the negative control (honest breaks must never heal).
test('divergence heal: after the bang every node is finite within 1 s, prev matched', () => {
// Shipped path: watchDivergence OFF, same rig shape as the break test above.
const scenario = () => {
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
const broke = [], heals = [];
r.events.on('break', (e) => broke.push(e));
r.events.on('heal', (e) => heals.push(e));
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 2);
assert(heals.length === 0, 'a healthy cloth healed something — the heal must be pure repair, never maintenance');
const poisoned = r.N + 1; // interior node, review's shape
r.pos[poisoned * 3 + 1] = NaN;
// step one substep at a time so the break substep is observable exactly
let steps = 0;
while (!broke.some((e) => e.reason === 'divergence') && steps < Math.round(2 / SIM_DT)) {
r.step(SIM_DT, w, (2 + steps * SIM_DT)); steps++;
}
assert(broke.some((e) => e.reason === 'divergence'), 'poison never broke a corner — the review\'s break regressed');
assert(heals.length > 0 && heals[0].nodes > 0, 'the divergence break fired and the heal did not');
// Immediately after the break substep: finite, and the healed node's prev
// is MATCHED — verlet must read zero velocity out of the heal, not a launch.
for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite right after the heal substep');
for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite right after the heal substep');
for (let k = 0; k < 3; k++) {
assert(r.pos[poisoned * 3 + k] === r.prev[poisoned * 3 + k],
'healed node has pos != prev — the heal invented velocity for verlet to integrate');
}
// ...and the spec's bound: still finite a full second of sim later.
runStorm(r, w, 1);
for (const v of r.pos) assert(Number.isFinite(v), 'cloth went non-finite again inside 1 s — the heal did not hold');
for (const v of r.water) assert(Number.isFinite(v), 'water went non-finite inside 1 s of the heal');
return { pos: Float64Array.from(r.pos), heals: heals.length };
};
const a = scenario(), b2 = scenario();
assert(a.pos.length === b2.pos.length, 'determinism runs differ in size');
for (let i = 0; i < a.pos.length; i++) assert(a.pos[i] === b2.pos[i], `heal is nondeterministic: node component ${i} differs across identical runs`);
return `poison → break → healed (${a.heals} heal event(s)), finite 1 s on, byte-equal across two runs`;
});
test('divergence heal: the NaN pond is zeroed with the positions, and stays zeroed under live rain', () => {
// Poison shape 2: divergence with the WATER poisoned too. This is what a real
// blow-up leaves behind while it rains — NaN face normals feed NaN into
// _nodeFlat and the rain-add writes it into the water field — so the test
// injects both halves deterministically rather than racing the rain clock.
// A heal that only fixed positions would leave pondMass() NaN forever: the
// weight guard's `w[n] > 0` happens to filter NaN today, but the HUD's
// ponding line, the broom's centroid and the conservation asserts all read
// the water field directly, and one refactor of that guard makes a NaN pond
// live poison. The heal zeroes it at the same moment it heals the cloth.
const r = rig(HEIGHTS_FLAT, { hw: UNBREAKABLE });
r.watchDivergence = false; // shipped path, as above
const broke = [], heals = [];
r.events.on('break', (e) => broke.push(e));
r.events.on('heal', (e) => heals.push(e));
const w = realWind(); // real storm: rain keeps landing after the heal
runStorm(r, w, 40);
assert(r.pondMass() > 50, `only ${r.pondMass().toFixed(0)} kg ponded before the poison — the water half of this test is vacuous`);
const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
r.water[belly] = NaN;
r.pos[belly * 3 + 1] = NaN;
runStorm(r, w, 2);
assert(broke.some((e) => e.reason === 'divergence'), 'the poisoned belly never broke a corner — the review\'s break regressed');
assert(heals.length > 0, 'the poison broke a corner and nothing healed');
runStorm(r, w, 1);
for (const v of r.pos) assert(Number.isFinite(v), 'cloth non-finite 1 s after the heal — the pond re-poisoned it');
for (const v of r.water) assert(Number.isFinite(v), 'water still non-finite after the heal — the heal is not zeroing the pond');
assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after the heal — the HUD broom line would read NaN');
// Honest disclosure, measured: on the storm path above the water is ALREADY
// clean by heal time, because the divergence break's dumpPond('corner blew')
// fill(0)s the pond first — so the asserts above cannot isolate the heal's
// own water pass. Pin that contract at the unit level instead: the heal must
// zero non-finite water WITHOUT a dump in front of it, so the second wire is
// live the day the dump's semantics change.
r.water[belly] = NaN;
const healedNodes = r._healNonFinite();
assert(r.water[belly] === 0, 'heal left NaN water in place — it is leaning on dumpPond to clean the pond');
assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after a direct heal');
return `poisoned belly + pond → divergence break at t=${broke.find((e) => e.reason === 'divergence').t.toFixed(1)}s → healed, ` +
`pond finite under live rain; direct heal zeroes NaN water (${healedNodes} position(s) touched)`;
});
test('divergence heal: negative control — honest overload breaks never trigger it', () => {
// The heal must be IN ADDITION to the break, not a hand that touches every
// failure: a cheap rig blowing corners honestly (finite loads, real weather)
// must ride the whole storm without one heal event. If this goes red, the
// heal has started editing healthy state, which is a sim change wearing a
// repair's name.
const w = makeStubWind({ seed: 11, stormLen: 90 });
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // the flogging test's rig: it blows corners
const broke = [], heals = [];
r.events.on('break', (e) => broke.push(e));
r.events.on('heal', (e) => heals.push(e));
runStorm(r, w, 90);
assert(broke.length > 0, 'nothing blew — the control is vacuous, pick a cheaper rig');
assert(broke.every((e) => e.reason !== 'divergence'), 'an honest overload break carried reason divergence');
assert(heals.length === 0, `${heals.length} heal event(s) on honest breaks — the heal is touching finite state`);
return `${broke.length} honest break(s), zero heals — the heal only answers divergence`;
});
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.
@ -359,6 +467,65 @@ test('determinism: variable frame dt matches fixed dt', () => {
return 'ragged frame times converge on the fixed-dt trace';
});
// SPRINT16 pool (B) — ROADMAP's "the >83 ms frame determinism bound
// (documented, un-tested)", now tested — and MEASURED SHARPER than documented.
// step() burns wall time in SIM_DT chunks up to MAX_SUBSTEPS(5) per call, and
// `if (n === MAX_SUBSTEPS) this._acc = 0` fires whenever a call CONSUMES five
// substeps — including when the leftover residual was a perfectly legal
// sub-SIM_DT remainder. So the unconditionally-lossless frame bound is
// (MAX_SUBSTEPS 1) × SIM_DT ≈ 66.7 ms (a 6783 ms frame may drop up to one
// SIM_DT of residual depending on accumulator phase; ≥ 83 ms always drops).
// Pinned AS MEASURED, not retuned: every trace ever recorded was made under
// this guard, and no real frame lives in the 6783 ms gap on purpose. If the
// guard is ever refined to `&& this._acc >= SIM_DT` (making 83 ms the true
// bound), move SAFE_MAX with it in the same commit — this header is the
// documented waiver ROADMAP asked for, THREADS [B] SPRINT16 has the numbers.
test('determinism bound: frames under 66.7 ms hold the trace; a hitch past 83 ms drops time by design', () => {
const SAFE_MAX = 0.066; // just under (MAX_SUBSTEPS 1) × SIM_DT — see header
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
const fixed = rig(HEIGHTS_HYPAR);
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
// Half 1: ragged frames spanning the WHOLE legal range converge on the
// fixed trace (the existing 424 ms test's promise, extended to the bound).
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
const ragged = rig(HEIGHTS_HYPAR);
const rand = rng(31);
let acc = 0;
while (acc < 19.5) {
const dt = 0.004 + rand() * (SAFE_MAX - 0.004);
ragged.step(dt, w2, acc);
acc += dt;
}
// Compare at equal SIM clocks, not equal wall clocks: a random frame sum
// overshoots the target by up to SAFE_MAX — up to 3 extra substeps of honest
// evolution, an off-by-N comparison rather than a determinism failure. Top
// up with fixed steps until both rigs have burned identical sim time.
while (ragged.t < fixed.t - 1e-9) { ragged.step(SIM_DT, w2, acc); acc += SIM_DT; }
assert(ragged.t === fixed.t, `sim clocks differ after top-up: ${ragged.t} vs ${fixed.t}`);
for (let k = 0; k < 4; k++) {
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N on frames inside the lossless bound — MAX_SUBSTEPS shrank?`);
}
assert(acc - ragged.t < SIM_DT + 1e-9,
`${((acc - ragged.t) * 1000).toFixed(1)} ms of wall time went missing on legal frames — the guard is dropping time early`);
// Half 2: one 120 ms hitch loses exactly the time past 5 substeps — the sim
// shrugs, it does not catch up, and the clock says so. This is the bound
// BEING a bound; if this half goes red the spiral guard is gone.
const w3 = makeStubWind({ seed: 5, stormLen: 20 });
const hitched = rig(HEIGHTS_HYPAR);
const HITCH = 0.120, dropped = HITCH - 5 * SIM_DT; // ≈ 36.7 ms lost
let fed = 0;
hitched.step(HITCH, w3, 0); fed += HITCH;
while (fed < 20) { hitched.step(SIM_DT, w3, fed); fed += SIM_DT; }
const deficit = fed - hitched.t;
assert(deficit > dropped - SIM_DT - 1e-9 && deficit < dropped + SIM_DT + 1e-9,
`fed ${fed.toFixed(3)} s of wall time, sim clock reads ${hitched.t.toFixed(3)} — deficit ${deficit.toFixed(4)} s, ` +
`expected ≈${dropped.toFixed(4)} s (one hitch's overflow). The spiral guard moved`);
return `frames ≤${(SAFE_MAX * 1000).toFixed(0)} ms: byte-faithful, no time lost; one 120 ms hitch drops ${(deficit * 1000).toFixed(1)} ms as designed`;
});
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
// night, and step() runs in EVERY phase once rigged — so the clock ran on

291
web/world/js/stormgen.js Normal file
View File

@ -0,0 +1,291 @@
'use strict';
// SHADES — Lane C — makeStorm(seed): seeded storms inside authored envelopes.
//
// SPRINT16 gate 3.2 — GROUNDWORK, not a shipped night. ROADMAP arc 4's
// roguelite ("procedurally generated storm sequences inside authored
// envelopes") eats this; nothing in NIGHTS uses it yet, and nothing should
// until that arc's own sprint prices it.
//
// The design rule, stated once: an ENVELOPE is authored (a designer says what
// this storm family is allowed to be — every knob a [lo, hi] range, every
// curve knot a fixed time with a ranged value), and a SEED picks one storm
// from inside it. The generator draws every value in a FIXED order from one
// mulberry32 stream, so:
// · same seed + same envelope → the SAME def, byte-equal under
// JSON.stringify (the determinism the whole sim is built on),
// · a generated def carries its seed, so the gust timeline downstream
// (buildGustTimeline reads def.seed) is seeded by the same number,
// · every draw is range-clamped by construction, and checkEnvelope() can
// verify conformance FROM THE DEF ALONE — it recomputes, it does not
// trust the generator, so a hand-edited def fails honestly.
//
// Pure on purpose: imports weather.core only (no THREE, no DOM, no fetch), so
// this runs under the node one-second loop exactly like the storm curves do.
//
// What an envelope deliberately does NOT range: knot TIMES (fixed per family —
// a family whose acts move is two families), telegraph (the honesty rule is
// not tunable), and GUST envelope shape (weather.core owns it). The change's
// dirCurve is BUILT from the drawn change time so the windchange event and the
// physics can never drift apart — the validator's own trap, made structural.
import { mulberry32, validateStorm, GUST } from './weather.core.js';
/** Round to 3 decimals keeps generated defs readable and diffs stable.
* Determinism does not depend on this (same ops same doubles); byte-equal
* JSON just reads better at 3 places than at 17. */
const r3 = (v) => Math.round(v * 1000) / 1000;
/**
* The authored envelopes, as data. One family ships as groundwork: the
* soaker family, ranged around storm_06's MEASURED personality (fabric_bet
* probe, THREADS 2026-07-20) so every member keeps the night's thesis
* pea stones under the cloth aperture, heavy rain, mild wind while the
* seed moves the acts around inside it.
*
* Envelope grammar (checkEnvelope enforces all of it):
* duration fixed seconds
* base { t: [knot times, first 0, last duration], v: [[lo,hi]] }
* gusts every field [lo,hi]; minGap.lo must be GUST.TOTAL or
* gusts could overlap (validator red)
* change { at:[lo,hi], over:[lo,hi], telegraph: fixed,
* startDir:[lo,hi], swing:[lo,hi] } swing is ADDED to
* the start direction; |swing| must be 0.6 everywhere
* in range (validator demands 0.5 rad of real turn)
* dirWander { amp:[lo,hi], rate:[lo,hi] }
* spatial fixed (spatial texture is family identity, not weather)
* rain { peakMmPerHour:[lo,hi], t: [knot times], v: [[lo,hi]] }
* hail null, or { size:[lo,hi], count:[lo,hi] (integer),
* window:[t0,t1], hold:[lo,hi], intensity:[lo,hi],
* ramp: fixed, fade: fixed }
* sky { darkness:[lo,hi], cloudScroll: fixed, night: fixed }
*/
export const ENVELOPES = {
soaker_family: {
name: 'soaker_family',
duration: 90,
base: {
t: [0, 15, 30, 50, 70, 90],
v: [[4, 6], [6, 7.5], [7.5, 9], [8.5, 10], [8, 9.5], [6.5, 8]],
},
gusts: {
firstAt: [3, 6], minGap: [5.5, 7], maxGap: [9, 12],
powBase: [1.5, 2.5], powRand: [2, 4], powRamp: [1.5, 2.5],
downdraftOfTotal: [0.25, 0.35],
},
change: {
at: [36, 52], over: [6, 10], telegraph: 6,
startDir: [0.5, 0.8], swing: [-1.5, -1.1],
},
dirWander: { amp: [0.15, 0.25], rate: [0.08, 0.12] },
spatial: { amp: 0.18, scale: 11, advect: 0.5 },
rain: {
peakMmPerHour: [38, 50],
t: [0, 10, 25, 40, 60, 80, 90],
v: [[0.1, 0.2], [0.35, 0.55], [0.65, 0.85], [0.8, 0.95], [0.9, 1.0], [0.75, 0.9], [0.6, 0.8]],
},
hail: {
size: [0.4, 0.55], count: [3, 5], window: [14, 78],
hold: [4, 9], intensity: [0.3, 0.55], ramp: 2, fade: 3,
},
sky: { darkness: [0.8, 0.9], cloudScroll: 0.07, night: true },
},
};
/**
* One storm from inside the envelope. Deterministic: same (seed, envelope)
* byte-equal def, proven by the selftest with JSON.stringify.
*
* @param {number} seed any uint32
* @param {object} [env] an ENVELOPES entry (defaults to the soaker family)
* @returns {object} a storm def that passes validateStorm and checkEnvelope
*/
export function makeStorm(seed, env = ENVELOPES.soaker_family) {
const rng = mulberry32(seed >>> 0);
const draw = ([lo, hi]) => lo + rng() * (hi - lo);
const drawInt = ([lo, hi]) => lo + Math.floor(rng() * (hi - lo + 1e-9));
// Draw order is the contract: base knots, gusts, change, wander, rain
// (peak then knots), hail (size, count, times, holds, intensities), sky.
// Reordering these lines is a breaking change to every seed ever noted.
const baseCurve = env.base.t.map((t, i) => [t, r3(draw(env.base.v[i]))]);
const g = env.gusts;
const gusts = {
firstAt: r3(draw(g.firstAt)),
minGap: r3(draw(g.minGap)),
maxGap: r3(draw(g.maxGap)),
powBase: r3(draw(g.powBase)),
powRand: r3(draw(g.powRand)),
powRamp: r3(draw(g.powRamp)),
downdraftOfTotal: r3(draw(g.downdraftOfTotal)),
};
const at = r3(draw(env.change.at));
const over = r3(draw(env.change.over));
const startDir = r3(draw(env.change.startDir));
const swing = r3(draw(env.change.swing));
const endDir = r3(startDir + swing);
// Built FROM the drawn change, so event and physics agree by construction:
// hold until just before the change, swing across [at, at+over], settle a
// touch past it, hold to the end. All knot times clamped inside duration.
const settle = Math.min(env.duration, at + over + 14);
const dirCurve = [
[0, startDir],
[r3(at - 4), startDir],
[at, r3(startDir - swing * 0.15)],
[r3(at + over), endDir],
[r3(settle), r3(endDir - 0.05)],
[env.duration, r3(endDir - 0.05)],
];
const dirWander = { amp: r3(draw(env.dirWander.amp)), rate: r3(draw(env.dirWander.rate)) };
const rain = {
peakMmPerHour: r3(draw(env.rain.peakMmPerHour)),
curve: env.rain.t.map((t, i) => [t, r3(draw(env.rain.v[i]))]),
};
let hail = null;
if (env.hail) {
const size = r3(draw(env.hail.size));
const count = drawInt(env.hail.count);
// Jittered slots: split the window into `count` equal slots and drop one
// burst inside each — spread guaranteed, spacing seeded, no sort needed
// (slot order IS time order, so draw order stays stable per burst).
const [w0, w1] = env.hail.window;
const slot = (w1 - w0) / count;
const bursts = [];
for (let i = 0; i < count; i++) {
const t = r3(w0 + slot * i + rng() * slot * 0.6);
bursts.push({
t,
ramp: env.hail.ramp,
hold: r3(draw(env.hail.hold)),
fade: env.hail.fade,
intensity: r3(draw(env.hail.intensity)),
});
}
hail = { size, bursts };
}
const sky = {
darkness: r3(draw(env.sky.darkness)),
cloudScroll: env.sky.cloudScroll,
};
if (env.sky.night) sky.night = true;
return {
name: `${env.name} seed ${seed >>> 0}`,
blurb: `Generated from the ${env.name} envelope — one of its storms, picked by seed ${seed >>> 0}.`,
_generated: { envelope: env.name, seed: seed >>> 0 },
seed: seed >>> 0,
duration: env.duration,
baseCurve,
gusts,
dirCurve,
dirWander,
spatial: { ...env.spatial },
events: [
{ t: at, type: 'windchange', telegraph: env.change.telegraph, over, text: 'the change comes through' },
],
rain,
...(hail ? { hail } : {}),
sky,
};
}
const inRange = (v, [lo, hi], eps = 1e-9) => Number.isFinite(v) && v >= lo - eps && v <= hi + eps;
/**
* Does this def sit inside this envelope? Recomputed from the DEF, never from
* remembering what the generator drew a hand-edited def must fail here on
* its own merits. Also runs validateStorm, so "inside the envelope" implies
* "loadable storm". Returns { ok, errors } like the validator.
*/
export function checkEnvelope(def, env = ENVELOPES.soaker_family) {
const errors = [];
const bad = (m) => errors.push(`${env.name}: ${m}`);
const v = validateStorm(def, def?.name ?? 'generated');
if (!v.ok) errors.push(...v.errors);
if (!def || typeof def !== 'object') return { ok: false, errors };
if (def.duration !== env.duration) bad(`duration ${def.duration} ≠ family's ${env.duration}`);
// base curve: same knot times, every value in range
const bc = def.baseCurve ?? [];
if (bc.length !== env.base.t.length) bad(`baseCurve has ${bc.length} knots, family has ${env.base.t.length}`);
else bc.forEach(([t, val], i) => {
if (t !== env.base.t[i]) bad(`baseCurve knot ${i} at t=${t}, family fixes t=${env.base.t[i]}`);
if (!inRange(val, env.base.v[i])) bad(`baseCurve[${i}] = ${val} outside [${env.base.v[i]}]`);
});
const g = def.gusts ?? {};
for (const k of ['firstAt', 'minGap', 'maxGap', 'powBase', 'powRand', 'powRamp', 'downdraftOfTotal']) {
if (!inRange(g[k], env.gusts[k])) bad(`gusts.${k} = ${g[k]} outside [${env.gusts[k]}]`);
}
if ((g.minGap ?? 0) < GUST.TOTAL) bad(`gusts.minGap ${g.minGap} < ${GUST.TOTAL} — family authored an overlap`);
// the change: one windchange event, inside the window, physics agreeing
const changes = (def.events ?? []).filter((e) => e.type === 'windchange');
if (changes.length !== 1) bad(`family storms carry exactly one windchange, got ${changes.length}`);
else {
const ev = changes[0];
if (!inRange(ev.t, env.change.at)) bad(`change at t=${ev.t} outside [${env.change.at}]`);
if (!inRange(ev.over ?? 6, env.change.over)) bad(`change.over ${ev.over} outside [${env.change.over}]`);
if ((ev.telegraph ?? 0) !== env.change.telegraph) bad(`change.telegraph ${ev.telegraph} ≠ family's ${env.change.telegraph}`);
const dc = def.dirCurve ?? [];
const start = dc[0]?.[1];
const end = dc[dc.length - 1]?.[1];
if (!inRange(start, env.change.startDir)) bad(`dirCurve start ${start} outside [${env.change.startDir}]`);
// net swing (allowing the settle's small overshoot), against the family's
const net = end - start;
const [sLo, sHi] = env.change.swing;
if (!inRange(net, [sLo - 0.1, sHi + 0.1])) bad(`net dir swing ${net?.toFixed?.(3)} outside [${sLo},${sHi}]±0.1`);
}
const w = def.dirWander ?? {};
if (!inRange(w.amp, env.dirWander.amp)) bad(`dirWander.amp ${w.amp} outside [${env.dirWander.amp}]`);
if (!inRange(w.rate, env.dirWander.rate)) bad(`dirWander.rate ${w.rate} outside [${env.dirWander.rate}]`);
const sp = def.spatial ?? {};
for (const k of ['amp', 'scale', 'advect']) {
if (sp[k] !== env.spatial[k]) bad(`spatial.${k} ${sp[k]} ≠ family's ${env.spatial[k]}`);
}
const rn = def.rain ?? {};
if (!inRange(rn.peakMmPerHour, env.rain.peakMmPerHour)) {
bad(`rain.peakMmPerHour ${rn.peakMmPerHour} outside [${env.rain.peakMmPerHour}]`);
}
const rc = rn.curve ?? [];
if (rc.length !== env.rain.t.length) bad(`rain.curve has ${rc.length} knots, family has ${env.rain.t.length}`);
else rc.forEach(([t, val], i) => {
if (t !== env.rain.t[i]) bad(`rain knot ${i} at t=${t}, family fixes t=${env.rain.t[i]}`);
if (!inRange(val, env.rain.v[i])) bad(`rain.curve[${i}] = ${val} outside [${env.rain.v[i]}]`);
});
if (env.hail) {
const h = def.hail;
if (!h) bad('family hails; def has no hail block');
else {
if (!inRange(h.size, env.hail.size)) bad(`hail.size ${h.size} outside [${env.hail.size}]`);
const bursts = h.bursts ?? [];
if (!inRange(bursts.length, env.hail.count)) bad(`${bursts.length} bursts outside [${env.hail.count}]`);
bursts.forEach((b, i) => {
if (!inRange(b.t, env.hail.window)) bad(`burst[${i}] at t=${b.t} outside window [${env.hail.window}]`);
if (!inRange(b.hold, env.hail.hold)) bad(`burst[${i}].hold ${b.hold} outside [${env.hail.hold}]`);
if (!inRange(b.intensity, env.hail.intensity)) bad(`burst[${i}].intensity ${b.intensity} outside [${env.hail.intensity}]`);
if (b.ramp !== env.hail.ramp) bad(`burst[${i}].ramp ${b.ramp} ≠ family's ${env.hail.ramp}`);
if (b.fade !== env.hail.fade) bad(`burst[${i}].fade ${b.fade} ≠ family's ${env.hail.fade}`);
});
if (h.withGustsAbove != null) bad('family hail is authored-bursts only; withGustsAbove is not in the envelope');
}
} else if (def.hail) {
bad('family does not hail; def carries a hail block');
}
const sk = def.sky ?? {};
if (!inRange(sk.darkness, env.sky.darkness)) bad(`sky.darkness ${sk.darkness} outside [${env.sky.darkness}]`);
return { ok: errors.length === 0, errors };
}

View File

@ -13,14 +13,14 @@ import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
stormsToPreload, verdictFor,
rigRecordFor, stormsToPreload, verdictFor,
} from '../main.js';
import { createGarden } from '../garden.js';
import { RiggingSession } from '../rigging.js';
import { createSkyFx } from '../skyfx.js';
import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
@ -253,7 +253,10 @@ export default async function run(t) {
// moment it landed (got 143, want 123), which is exactly its job — an
// invoice whose rows don't sum to its total is the one bug a player will
// definitely find.
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral, 'the ledger adds up as shown');
// SPRINT16: warrantyTotal joins the sum (0 on a history-less week, so
// this line is the formula's pin, not a behaviour change here).
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'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');
@ -534,6 +537,233 @@ export default async function run(t) {
assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first');
});
// =========================================================================
// SPRINT16 gate 1 — THE LEDGER: work history, warranty, reputation
// =========================================================================
// The fixtures speak the seam contract's shapes (THREADS [A] 2026-07-20):
// rc() is one corner of the rig record, cleanRun() a night nothing went
// wrong on. s16def matches the def the older week tests use — feeFor(20).
const s16def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const rc = (anchorId, over = {}) => ({
anchorId, hw: 'shackle', hwCost: 15, effRating: 3200,
broke: false, repaired: false, brokenAtDawn: false, ...over,
});
const cleanRun = (over = {}) => ({
hp: 80, win: true, collateral: [], intactHardwareValue: 0,
rigRecord: [rc('p1'), rc('p2'), rc('p3'), rc('p4')], ...over,
});
t.test('SPRINT16: the work history — the tally remembers what the corner flag forgets', () => {
// rigRecordFor is the value scoreRun calls (the Enter-guard lesson: the
// closure inside boot() is unreachable, the rule is not). The trap it
// exists to dodge: repairCorner() clears `broken`, so at dawn a corner
// that broke and was fixed is indistinguishable from one that never went —
// the EVENT tally is the only honest witness.
const mk = (id, hw, hint, broken) => ({ anchorId: id, hw, broken, anchor: { ratingHint: hint } });
const shackle = { name: 'shackle', cost: 15, rating: 3200 };
const carab = { name: 'carabiner', cost: 5, rating: 1200 };
const tally = new Map([
['q2', { broke: 1, repaired: 1 }], // broke, fixed — flag forgot, tally didn't
['q3', { broke: 2, repaired: 1 }], // broke twice, fixed once — still down
]);
const recs = rigRecordFor([
mk('q1', carab, 0.22, false), // held all night on the lying beam
mk('q2', shackle, 1, false), // hw at dawn = the spare, honestly
mk('q3', shackle, 1, true),
mk('q4', shackle, 1, true), // no tally entry at all (divergence-shaped) — still recorded
], tally);
const by = (id) => recs.find((r) => r.anchorId === id);
assertEq(by('q1').broke, false, 'a corner that held is not a break');
assertEq(by('q1').effRating, Math.round(1200 * 0.22),
'effRating is the number sail.js fails on — rating × hint, not the bare rating');
assertEq(by('q2').broke, true, 'the tally remembers the break the cleared flag forgot');
assertEq(by('q2').repaired, true, 'and standing at dawn = repaired');
assertEq(by('q2').brokenAtDawn, false);
assertEq(by('q3').repaired, false, 'broke twice, fixed once: brokenAtDawn, not repaired');
assertEq(by('q3').brokenAtDawn, true);
assertEq(by('q4').broke, true, 'a broken corner with no tally entry is still a break — belt AND braces');
for (const r of recs) {
assert(!(r.repaired && r.brokenAtDawn), `${r.anchorId}: repaired and brokenAtDawn must partition broke`);
assertEq(r.repaired || r.brokenAtDawn, r.broke, `${r.anchorId}: every break ends exactly one way`);
}
// …and the week KEEPS it: the settlement carries the record verbatim.
const w = createWeek();
const s = w.settle(cleanRun({ rigRecord: recs }), s16def, 0);
assertEq(s.rig, recs, 'settlement.rig IS the work history — the ledger keeps what the night computed');
});
t.test('SPRINT16: warranty fires ONLY on corners still broken at dawn — a repair books nothing', () => {
const w = createWeek();
w.settle(cleanRun({ rigRecord: [
rc('q2', { broke: true, repaired: true }), // mid-storm repair: NOT a warranty item (spec)
rc('q3', { broke: true, brokenAtDawn: true }), // rode it out broken: THE item
rc('p1'), rc('p2'),
] }), s16def, 0);
w.advance();
const q = w.quote(s16def);
assertEq(q.warranty.length, 1, 'one item: the repaired corner books nothing');
assertEq(q.warranty[0].anchorId, 'q3');
assertEq(q.warranty[0].hw, 'shackle');
assertEq(q.warranty[0].cost, 15, "the broken hardware's shop price — parts; the labour is the point, unpaid");
assertEq(q.warrantyTotal, 15);
assert(q.warranty[0].addr, 'the item says whose yard it came from');
// The item lives ONE morning: settle tonight clean, and tomorrow books none.
w.settle(cleanRun(), s16def, 0);
w.advance();
assertEq(w.quote(s16def).warranty.length, 0, 'a warranty line does not haunt the week');
});
t.test('SPRINT16: quote==settle holds WITH a warranty line — the deduction is at quote time', () => {
const w = createWeek();
w.settle(cleanRun({ rigRecord: [
rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
] }), s16def, 0);
w.advance();
const q = w.quote(s16def);
assert(q.warrantyTotal > 0, 'precondition: tonight carries the deduction');
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal,
'the quote CONCLUDES at the deducted number — the sheet does not hide it');
const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
assertEq(s.warrantyTotal, q.warrantyTotal, 'same items, same source — derived, never stored twice');
assertEq(s.fee, q.base, 'the fee is the quoted fee');
assertEq(s.fee + s.bonus + s.clean - s.warrantyTotal, q.total,
'a perfect night pays exactly what the sheet quoted — the invariant, with teeth in it');
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
'and the invoice rows still sum to the total shown');
});
t.test('SPRINT16: rep — clean up, dawn-broken down, collateral down harder; the garden is NOT an input', () => {
const s1 = createWeek().settle(cleanRun(), s16def, 0);
assertEq(s1.repBefore, REP.START, 'the outfit starts neutral');
assertEq(s1.rep, REP.START + REP.CLEAN, 'a clean night moves the letterhead number up');
assert(s1.repReasons.includes('clean night'), 'with its reason stated');
const s2 = createWeek().settle(cleanRun({ rigRecord: [
rc('p1', { broke: true, repaired: true }), rc('p2'), rc('p3'), rc('p4'),
] }), s16def, 0);
assertEq(s2.repDelta, 0, 'broke-but-repaired is NEUTRAL: not clean, not a warranty — fixing it IS the job');
assert(s2.repReasons.join(' ').includes('repaired by dawn'), 'and the flat line says why');
const s3 = createWeek().settle(cleanRun({ rigRecord: [
rc('p1', { broke: true, brokenAtDawn: true }), rc('p2'), rc('p3'), rc('p4'),
] }), s16def, 0);
assertEq(s3.repDelta, REP.WARRANTY, 'riding it out broken costs the number');
const s4 = createWeek().settle(cleanRun({
collateral: [{ what: 'the carport', cost: 180 }],
}), s16def, 0);
assertEq(s4.repDelta, REP.COLLATERAL, 'their property beats your hardware');
assertLess(s4.repDelta, s3.repDelta, 'collateral hits HARDER than a blown corner — the spec\'s word');
// The pin that matters most: same rig, opposite garden — identical movement.
// hp and win are absent from the movement BY CONSTRUCTION, which is what
// makes night 5's designed loss free without an exemption to go stale.
const a1 = createWeek().settle(cleanRun({ hp: 100, win: true }), s16def, 0);
const b1 = createWeek().settle(cleanRun({ hp: 0, win: false }), s16def, 0);
assertEq(a1.repDelta, b1.repDelta, 'rep never reads the garden');
});
t.test("SPRINT16: night 5's designed loss costs NO rep — a clean rig still earns it", () => {
const w = createWeek();
for (let i = 0; i < 4; i++) w.advance();
assertEq(nightAt(4).gardenBeyondSaving, true, 'precondition: the flagged night');
// Garden lost by design, steel held, nothing of theirs broken: that was
// the whole job tonight (the verdict's own words), and the number says so.
const s = w.settle(cleanRun({ hp: 0, win: false }), s16def, 0);
assertEq(s.repDelta, REP.CLEAN,
'a designed loss the ledger punished would be the verdict lying again — it goes UP');
});
t.test('SPRINT16: rep prices the fee, and the multiplier is on the sheet the night it applies', () => {
const w = createWeek();
w.settle(cleanRun(), s16def, 0); // → ★3.5
w.advance();
const q = w.quote(s16def);
assertEq(q.rep, 3.5);
assertEq(q.feeMult, REP.multiplier(3.5));
assertEq(q.feeMult, 1.02, 'half-steps land EXACTLY on two decimals — the printed number is the number used');
assertEq(q.base, Math.round(PAY.feeFor(20) * 1.02), 'the fee is priced on the standing');
if (typeof document !== 'undefined') {
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showForecast(
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
{ night: w.night, nights: w.nights, log: [], job: w.job,
quote: q, bank: w.bank, rep: w.rep }, () => {});
const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(doc('.docket .rep')?.textContent, 'standing ★ 3.5',
'the number is on the letterhead, under the ABN — E\'s contract');
assert((doc('.sched .row.headline span')?.textContent ?? '').includes('×1.02'),
'the multiplier prints on the fee row the night it applies');
} finally { hud.dispose(); }
}
// …and settle pays the multiplied fee the sheet quoted.
const s = w.settle(cleanRun({ hp: 100 }), s16def, 0);
assertEq(s.fee, q.base, 'trust became visible money, and the invoice honoured it');
assertEq(s.feeMult, q.feeMult);
// Neutral control: a fresh week prints no multiplier tag at all.
assertEq(createWeek().quote(s16def).feeMult, 1, 'neutral standing is ×1.00 — and the sheet stays quiet about it');
});
t.test('SPRINT16 negative control: a clean week books ZERO warranty items and ends rep-positive', () => {
const w = createWeek();
let items = 0;
for (let n = 0; n < 5; n++) {
items += w.quote(s16def).warranty.length; // counted off the sheet…
items += w.settle(cleanRun(), s16def, 0).warranty.length; // …and the invoice
w.advance();
}
assertEq(items, 0, 'no sheet and no invoice ever carried a warranty line');
assert(w.rep > REP.START, `the week ends rep-positive (★${w.rep})`);
assertEq(w.rep, REP.MAX, 'five clean nights ride the clamp to the top of the scale');
assertEq(w.log[4].warrantiesToDate, 0, 'and the end card has no receipts to show');
w.reset();
assertEq(w.rep, REP.START, 'reset() is a new outfit — rep goes back to neutral');
});
t.test('SPRINT16: the invoice carries the warranty line and speaks rep; the endings differ', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
const w = createWeek();
w.settle(cleanRun({ rigRecord: [
rc('q3', { broke: true, brokenAtDawn: true }), rc('p1'), rc('p2'), rc('p3'),
] }), s16def, 0);
w.advance();
const s = w.settle(cleanRun({ hp: 90 }), s16def, 0);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showAftermath({ hp: 90, cornersLost: 0, cornersTotal: 4, hailBlocked: 'x',
bill: 0, collateral: [], subtitle: 'test', verdict: 'test', win: true, week: s }, () => {});
const doc = (sel) => document.querySelector(`#hud-card ${sel}`);
const ledger = doc('.ledger')?.textContent ?? '';
assert(ledger.includes('warranty — Q3, shackle, no charge to the client'),
`the warranty line, worded as the spec words it — got: "${ledger}"`);
assert(ledger.includes('$15'), 'and priced at the parts\' shop price');
assert((doc('.docket .rep')?.textContent ?? '').includes('★'),
'rep is under the ABN on the invoice too — both papers, one number');
const repline = doc('.repline')?.textContent ?? '';
assert(repline.includes('reputation'), 'the verdict speaks rep');
assert(repline.includes('clean night'), `and states the reason: "${repline}"`);
hud.hideCard();
// The two endings the gate names, rendered and compared: they must not
// read the same, and rich-with-warranties must show its receipts.
hud.showEndCard({ ...s, outcome: 'gameover', rep: 4.5, warrantiesToDate: 0, grade: 'solvent' }, () => {});
const brokeClean = document.querySelector('#hud-card .kicker')?.textContent ?? '';
hud.hideCard();
hud.showEndCard({ ...s, outcome: 'win', rep: 1.5, warrantiesToDate: 4, grade: 'clean', held: 5 }, () => {});
const richWarranties = document.querySelector('#hud-card .kicker')?.textContent ?? '';
const endtext = document.querySelector('#hud-card .endtext')?.textContent ?? '';
assert(endtext.includes('warranty jobs booked'), 'rich-with-warranties shows its receipts');
assert(endtext.includes('reputation'), 'and the number is on the last card the game shows');
assert(brokeClean.length > 0 && richWarranties.length > 0 && brokeClean !== richWarranties,
'broke-but-clean and rich-with-warranties are DIFFERENT endings and must read as such');
} finally { hud.dispose(); }
});
// --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {
@ -557,15 +787,21 @@ export default async function run(t) {
// The enum was JSDoc for ten sprints — documentation cannot fail, which is
// exactly how a carport got typed 'post'. This is the assert that makes the
// contract real, so it must actually reject something.
//
// SPRINT16 (E's flag, defused): the bogus type was 'trampoline' — which is
// literally gate 5's third asset, deferred to arc 2. The day it joined
// ANCHOR_TYPE this fixture would have validated and the test gone red on a
// correct change, at the worst possible moment. The bogus type must be a
// thing the yard will NEVER offer.
const bogus = {
yard: { width: 10, depth: 10 }, gardenBed: { x: 0, z: 0, w: 2, d: 2 },
sun: { elevationDeg: 45, azimuthDeg: 0 },
posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'trampoline', work: 'cloth' }],
posts: [{ id: 'x1', x: 0, z: 0, h: 3, type: 'bouncy_castle', work: 'cloth' }],
};
let threw = null;
try { validateSite(bogus, 'bogus'); } catch (err) { threw = err.message; }
assert(threw, 'a site with an unknown anchor type must fail loud');
assert(/trampoline/.test(threw), 'and must NAME the type it rejected');
assert(/bouncy_castle/.test(threw), 'and must NAME the type it rejected');
// ...and must not reject the types that are real, including the new ones.
for (const type of ANCHOR_TYPE) {
const ok = { ...bogus, posts: [{ id: 'x1', x: 0, z: 0, h: 3, type, work: 'cloth' }] };

View File

@ -16,7 +16,7 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, assertClose, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
import { loadSite, createWorld } from '../world.js';
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
// that copied main.js's two lines would agree with a copy of the game forever,
@ -30,6 +30,11 @@ import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.js';
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
// SPRINT16 gate 3: the membrane night's pin flies THE audit chain (gardenfly),
// and the seeded generator must satisfy the storm-side envelope harness.
import { flyGarden, hardwareByName } from '../../../../tools/site_audit/gardenfly.js';
import { stormEnvelope } from '../../../../tools/storm_envelope/envelope.js';
import { makeStorm } from '../stormgen.js';
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
@ -39,6 +44,9 @@ const STORMS = [
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
// and night 5 (less wind, 2.8× the hail).
'storm_03b_earlybuster', 'storm_02b_icenight',
// SPRINT16 gate 3.1: the pea-hail soaker — the first night the default
// fabric is the wrong answer. Its membrane-vs-cloth pin is below.
'storm_06_soaker',
];
/** @param {import('../testkit.js').Suite} t */
@ -719,13 +727,17 @@ export default async function run(t) {
['rain', (s) => s.rainPeak],
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
];
// gate 3.3: the stone-size band obeys the same law as every other number
// on the card — nested, truth-holding, exact at lead 0. It lives at
// f.hail.size, so the key row carries its own accessor.
keys.push(['hail.size', (s) => s.hailSize, (f) => f.hail.size]);
for (const [name, def] of Object.entries(storms)) {
for (const [k, truthOf] of keys) {
for (const [k, truthOf, getBand] of keys) {
let prev = null;
// walk the week backwards: the far end -> tonight
for (let i = 40; i >= 0; i--) {
const f = forecastFor(def, i / 40);
const b = f[k];
const b = getBand ? getBand(f) : f[k];
const truth = truthOf(f.truth);
assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9,
`${name}.${k} @lead ${i / 40}: band ${b.lo}${b.hi} rules out the truth ${truth}`);
@ -738,8 +750,9 @@ export default async function run(t) {
}
// and it must ARRIVE: tonight is the truth, not merely a narrow band
const exact = forecastFor(def, 0);
assert(exact[k].lo === exact[k].hi,
`${name}.${k}: tonight should be exact, got ${exact[k].lo}${exact[k].hi}`);
const eb = getBand ? getBand(exact) : exact[k];
assert(eb.lo === eb.hi,
`${name}.${k}: tonight should be exact, got ${eb.lo}${eb.hi}`);
}
}
});
@ -1093,4 +1106,182 @@ export default async function run(t) {
+ 'different quantities that merely nearly coincide, and conflating them is the '
+ 'mistake THREADS records A and C both nearly making');
});
// =========================================================================
// SPRINT16 GATE 3.3 — the stone and the rain rate join the card
t.test('gate 3.3: forecast stones and rain rate — worded, banded, exact tonight, never NaN', () => {
const soaker = storms.storm_06_soaker;
const tonight = forecastLines(soaker, 0);
// tonight is exact: the soaker's truth, in the fabric bet's own words
assert(tonight.stones === 'fine pea stones',
`the soaker's stones read "${tonight.stones}" — size 0.45 is the fine pea the cloth leaks, `
+ 'and the card is where the player learns that before spending a dollar');
assert(tonight.rainRate === 'rain to 44 mm/hr',
`the soaker's rain rate reads "${tonight.rainRate}" — want the exact 44 mm/hr at lead 0`);
assert(/hail likely/.test(tonight.rain),
`the soaker (14.9 hail-seconds) should call its hail likely: "${tonight.rain}"`);
// the sizes cloth stops dead read as the big words
assert(forecastLines(storms.storm_02_wildnight, 0).stones === 'golf ball stones',
`wild night stones read "${forecastLines(storms.storm_02_wildnight, 0).stones}"`);
assert(forecastLines(storms.storm_03_southerly, 0).stones === 'pea stones',
`southerly stones read "${forecastLines(storms.storm_03_southerly, 0).stones}" — 0.7 is `
+ "storm_03's own comment's word");
// a hail-free storm advertises nothing
assert(forecastLines(storms.storm_01_gentle, 0).stones === '',
'the gentle storm advertised stones it does not have');
// far out, the rate hedges as a band (the stone word may or may not span
// two words — the BAND honesty is pinned in the resolving test above)
const far = forecastLines(soaker, 1);
assert(//.test(far.rainRate), `a week out the rain rate should hedge, got "${far.rainRate}"`);
assert(/fine pea/.test(far.stones), `the soaker a week out still forecasts pea-family stones, got "${far.stones}"`);
// no NaN/undefined reaches the card, any storm, 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 ['stones', 'rainRate']) {
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
}
}
}
});
// =========================================================================
// SPRINT16 GATE 3.2 — the generator satisfies the storm-side harness
t.test('gate 3.2: a generated storm flies the storm_envelope harness, deterministically', () => {
const anchors = [
{ id: 'a', type: 'post', pos: { x: 0, y: 4, z: 0 } },
{ id: 'b', type: 'post', pos: { x: 5, y: 4, z: 3 }, ratingHint: 0.45 },
];
const r1 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
const r2 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
for (const r of r1.rows) {
assert(Number.isFinite(r.peak) && r.peak > 0, `vacuous envelope row for ${r.id}: peak ${r.peak}`);
assert(Number.isFinite(r.dose) && r.dose > 0, `vacuous dose for ${r.id}`);
assert(r.tPeak >= 0 && r.tPeak <= 90, `${r.id} peaked outside the storm at t=${r.tPeak}`);
}
r1.rows.forEach((r, i) => {
const s = r2.rows[i];
assert(r.peak === s.peak && r.tPeak === s.tPeak && r.dose === s.dose,
`same seed, two envelope runs disagree at ${r.id}: ${r.peak} vs ${s.peak}`);
});
// hint reaches the ranking exactly as it does for authored storms
assert(r1.rows[0].id === 'b',
`equal-ish wind, hint 0.45 vs 1.0 — 'b' must rank first, got ${r1.rows.map((r) => r.id).join(',')}`);
// and the seed is a real variable through the harness, not just the JSON
const other = stormEnvelope({ anchors, stormDef: makeStorm(8) });
assert(other.rows.some((r, i) => r.peak !== r1.rows[i].peak),
'seeds 7 and 8 measured identically through the harness — the seed is not reaching the wind');
});
// =========================================================================
// SPRINT16 GATE 3.1 — THE MEMBRANE NIGHT, PINNED THROUGH THE REAL CHAIN
//
// storm_06_soaker exists to make the F key matter: pea stones under the
// cloth aperture, heavy rain, mild wind. The pin flies gardenfly — THE
// audit scoring chain (windForSite → attach → skyfx → garden.js), dressed
// site_02 anchors, live sway, funnel ON — with porosity the ONLY variable
// between the cloth and membrane flights. Numbers measured 2026-07-20
// (fabric_bet probe, post-ruling tree): bare 17.5 · cloth $75 ring 42.3 ·
// membrane $75 ring 96.5 · membrane $60 ring 68.8. Thresholds pin the
// BOUNDARIES the night stakes, with margin, not the digits.
const soakerDef = storms.storm_06_soaker;
const soakerSite = site02; // loaded above for gate 2.3
let soakerFlights = null;
{
// garden_probe's proxy pattern: world.anchors fly THEMSELVES, and `use`
// re-points the sway closures at the storm actually flying (the frozen-
// tree landmine — tr1/tr1b are the pin's own corners, they must sway).
let cur = createStubWind({ calm: true });
const proxy = {
sample: (p, t2, o) => cur.sample(p, t2, o || new THREE.Vector3()),
speedAt: (p, t2) => cur.speedAt(p, t2),
rainAt: (t2) => (cur.rainAt ? cur.rainAt(t2) : 0),
rainMmPerHour: (t2) => (cur.rainMmPerHour ? cur.rainMmPerHour(t2) : 0),
gustTelegraph: (t2) => (cur.gustTelegraph ? cur.gustTelegraph(t2) : null),
eventsBetween: (a, b) => (cur.eventsBetween ? cur.eventsBetween(a, b) : []),
setSheltersFromTrees() {},
};
const use = (w) => { cur = w; };
try {
const world = createWorld(new THREE.Scene(), { wind: proxy, site: structuredClone(soakerSite) });
await world.dress(); // graybox would move the tree corners — fail loud instead
const bed = world.gardenBed;
const ring = ['q1', 'q2', 'q3', 'q4'];
const hw75 = ['rated shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
const hw60 = ['shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
const hw20 = Array(4).fill(hardwareByName('carabiner'));
const common = { anchors: world.anchors, bed, stormDef: soakerDef, siteDef: soakerSite, use };
soakerFlights = {
bare: flyGarden({ ...common }),
cloth75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0.30 }),
mem75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0 }),
mem60: flyGarden({ ...common, ids: ring, hw: hw60, porosity: 0 }),
cloth20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0.30 }),
mem20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0 }),
};
} catch (err) {
soakerFlights = { error: err };
}
}
t.test('GATE 3.1: the soaker flips the fabric bet — membrane wins the night cloth loses', () => {
if (soakerFlights?.error) throw soakerFlights.error;
const { bare, cloth75, mem75, mem60 } = soakerFlights;
// vacuity guards first: the night must actually threaten, through hail
assert(bare.byHail > 40,
`bare bed took only ${bare.byHail} HP of hail — the soaker has stopped soaking and this pin proves nothing`);
assert(bare.hp < 30, `bare bed survived at ${bare.hp} — the night has no teeth`);
// the primitive the whole night stands on: these stones pass a 2 mm weave
const size = soakerDef.hail.size;
assert(hailBlockFor(size, 0.30) < 0.30,
`cloth blocks ${(hailBlockFor(size, 0.30) * 100).toFixed(0)}% of size-${size} stones — the soaker's `
+ 'hail has drifted above the weave aperture and the bet is dead');
assert(hailBlockFor(size, 0) === 1, 'membrane stopped blocking — hailBlockFor broke');
// CLOTH LOSES: the best measured cloth line in the yard (this ring, $75)
// ends under the win line — the default fabric is the wrong answer.
assert(cloth75.hp < 50,
`the $75 ring on CLOTH reads ${cloth75.hp} — over the win line, so the default fabric wins the `
+ 'membrane night and storm_06 is a decoration (retune the hail spectrum, per the gate)');
// ...but visibly beats bare: the cloth is leaking, not absent
assert(cloth75.hp > bare.hp + 10,
`cloth ${cloth75.hp} vs bare ${bare.hp} — cloth is doing nothing; the leak should be partial`);
// MEMBRANE WINS, same line, one variable: the F key is the night
assert(mem75.hp > 90 && mem75.state === 'full',
`the SAME $75 ring on MEMBRANE reads ${mem75.hp} ${mem75.state} — want >90 FULL (measured 96.5)`);
assert(mem75.cornersLost < 2,
`membrane $75 lost ${mem75.cornersLost} corners — main.js's win rule (lost<2) fails, the load is not affordable`);
assert(mem75.cost <= 80, `the membrane line costs $${mem75.cost} — the $80 wallet cannot buy the night's answer`);
// and the bet is affordable a tier down: the $60 ring also wins on membrane
// (measured 68.8 FULL, one pond corner late — the broom's job in play)
assert(mem60.hp > 60 && mem60.cornersLost < 2,
`the $60 ring on membrane reads ${mem60.hp} with ${mem60.cornersLost} lost — the bet should not `
+ 'need the last dollar of the wallet');
// THE SEPARATION, pinned: same line, same steel, fabric alone is worth
// most of a garden (measured Δ54.2 — threshold carries real margin)
assert(mem75.hp - cloth75.hp > 40,
`membrane-vs-cloth separation ${(mem75.hp - cloth75.hp).toFixed(1)} HP on the same $75 line — `
+ 'under 40, the F key has stopped being the night');
});
t.test('GATE 3.1 guard: membrane is not a free win — on cheap steel it tears', () => {
// The inverse control, so the night cannot rot into "always press F":
// the doubled load must make membrane WORSE than cloth on carabiners.
// (Measured: cloth $20 ring 24.5 hp / 2 lost · membrane $20 ring 19.1 hp
// / 3 lost.) Without this, fabric would be a one-way upgrade and the bet
// a formality — the same decoration failure from the other side.
if (soakerFlights?.error) throw soakerFlights.error;
const { cloth20, mem20 } = soakerFlights;
assert(cloth20.cornersLost > 0 || mem20.cornersLost > 0,
'neither cheap ring lost a corner — carabiners hold the soaker and this control proves nothing');
assert(mem20.cornersLost > cloth20.cornersLost || mem20.hp < cloth20.hp,
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
});
}

View File

@ -100,6 +100,22 @@ const ASSETS = [
nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] },
{ name: 'swing_set_01_wrecked', h: [0.80, 1.05],
nodes: ['frame', 'crossbar', 'swings'] },
// SPRINT16 gate 5 — the next temptations. Height floors carry design claims:
// the pergola's 2.55 floor sits ABOVE the swing set's 2.15 ceiling (the rung
// it out-rates must stand taller than the rung below it), and the pool
// fence's band brackets 1.2 m because the compliance height IS the flavour.
{ name: 'pergola_01', h: [2.55, 2.85],
nodes: ['deck', 'posts', 'beams', 'rafters',
'pergola_anchor_01', 'pergola_anchor_02'] },
{ name: 'pergola_01_wrecked', h: [2.55, 2.85],
nodes: ['deck', 'posts', 'beams', 'beam_down', 'rafters_down'] },
{ name: 'glasshouse_01', h: [2.15, 2.40],
nodes: ['frame', 'panes', 'door', 'ridge_anchor'] },
{ name: 'glasshouse_01_wrecked', h: [2.15, 2.40],
nodes: ['frame', 'door', 'panes_left', 'shards'] },
{ name: 'pool_kit_01', h: [1.12, 1.32],
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
'fence_post_01', 'fence_post_14'] },
];
function sizeOf(gltf) {
@ -502,7 +518,9 @@ export default async function run(t) {
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
['fence_panel', 'fence_panel_snapped'],
['swing_set_01', 'swing_set_01_wrecked'],
['house_yardside', 'house_yardside_wrecked']]) {
['house_yardside', 'house_yardside_wrecked'],
['pergola_01', 'pergola_01_wrecked'],
['glasshouse_01', 'glasshouse_01_wrecked']]) {
for (const n of [intact, broken]) {
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
assert(Math.abs(box.min.y) < 0.03,
@ -792,6 +810,246 @@ export default async function run(t) {
`gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across — the control is broken`);
});
// -------------------------------------------------------------------------
// SPRINT16 gate 5 — THE NEXT TEMPTATIONS. Three props, three different ways
// of saying no: the pergola says "yes, at 0.65" (the ladder's middle rung),
// the glasshouse says "no, and here is the bill if you make me" (the
// collateral apex), the pool ring says "no" fourteen times in a perfect
// circle. Every claim below is a number read off the shipped GLB.
// -------------------------------------------------------------------------
// The pergola's rating is pinned against the MANIFEST, not a literal — the
// gate-1.2 lesson applied to the asset that gate's mechanism was built FOR:
// a site declaring a node-less `pergola` anchor must resolve these exact
// extras, so the manifest entry existing (and being unambiguous) IS the
// feature. A pin that hard-coded 0.65 would stay green while the manifest
// silently dropped the type.
t.test('the pergola is the ladder\'s middle rung, and the manifest can resolve it', () => {
const types = FACTORY_ANCHOR_RATINGS.types;
const p = types.pergola;
assert(p, 'the manifest does not rate "pergola" — either the bake is missing or the '
+ 'type went AMBIGUOUS (its nodes disagree), and a node-less pergola anchor would '
+ 'fall back to the default hint: the sail_post bug shape, on the asset built to bury it');
assert(p.collateral === 'pergola', `manifest collateral is ${JSON.stringify(p.collateral)}, want "pergola"`);
const swing = types.swing_frame.rating_hint, post = types.post.rating_hint;
assert(p.rating_hint > swing && p.rating_hint < post,
`pergola rates ${p.rating_hint} — the rung must sit strictly between the swing frame `
+ `(${swing}, bolted to nothing) and the post (${post}, bolted to the planet); at either `
+ 'end the ladder loses the rung D endorsed and fascia-or-nothing is back');
const g = loaded.get('pergola_01');
assert(g, 'pergola_01 did not load');
for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) {
const o = g.scene.getObjectByName(n);
assert(o, `${n} missing`);
assert(o.userData?.anchor_type === 'pergola',
`${n} is typed ${JSON.stringify(o.userData?.anchor_type)} — a pergola is not a post; `
+ 'the type string is the player\'s pre-rig read and "post" promises concrete');
assert(o.userData?.rating_hint === p.rating_hint,
`${n} bakes ${o.userData?.rating_hint} but the manifest says ${p.rating_hint}`);
assert(o.userData?.collateral === 'pergola', `${n} must name what it takes with it`);
}
// The price ladder, read off the GLBs so site JSON and factory can't tell
// two stories: gutter < pergola < swing set < carport. The pergola bills
// UNDER the swing set because its failure is partial (a beam and a post;
// the bolts hold) where the swing's is total (the whole frame on the
// grass) — prices must agree with the wrecks the player actually sees.
const price = (a) => loaded.get(a)?.scene.getObjectByName(a)?.userData?.collateral_value;
const cost = price('pergola_01');
assert(typeof cost === 'number', 'pergola_01 carries no collateral_value');
const root = g.scene.getObjectByName('pergola_01');
assert(root.userData?.collateral_key === 'pergola',
'collateral_key must name the string the anchors carry, or the price prices nothing');
assert(cost > price('house_yardside') && cost < price('swing_set_01'),
`the pergola (${cost}) must sit between the gutter (${price('house_yardside')}) and the `
+ `swing set (${price('swing_set_01')}) — a partial timber repair beats a run of guttering `
+ 'and loses to a whole frame replacement');
const w = loaded.get('pergola_01_wrecked')?.scene.getObjectByName('pergola_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked pergola must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'pergola_01',
'the wreck must name its intact twin so A can pair the swap');
});
// The 0.65 story told by the wreck: a bolted structure fails PARTIALLY.
// The front beam and the rafters come down; the deck, the bolts and the
// back half stand. Measured, because "partial" is exactly the kind of word
// that erodes into "rubble" one art pass at a time.
t.test('the pergola fails partially: rafters hinge down, the deck stands, no anchor survives', () => {
const w = loaded.get('pergola_01_wrecked');
assert(w, 'pergola_01_wrecked did not load');
for (const n of ['pergola_anchor_01', 'pergola_anchor_02']) {
assert(!w.scene.getObjectByName(n),
`${n} survives the wreck — you cannot re-bolt a snapped post mid-storm, and an anchor `
+ 'that outlives its structure is the free-failure bug in a costume');
}
// The lean-to read: rafters still reach the surviving back beam AND rest
// on the deck — high at one end, low at the other, in the same node.
const down = new THREE.Box3().setFromObject(w.scene.getObjectByName('rafters_down'));
assert(down.max.y > 2.3,
`rafters_down tops out at y=${down.max.y.toFixed(2)} — they must still hang from the back beam`);
assert(down.min.y < 0.5,
`rafters_down bottoms out at y=${down.min.y.toFixed(2)} — the front ends should rest on the deck`);
const beamDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('beam_down'));
assert(beamDown.max.y < 0.55,
`beam_down tops out at y=${beamDown.max.y.toFixed(2)} — the torn beam lies on the deck`);
// Intact control: the rafters were UP, or "down" above means nothing.
const up = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('rafters'));
assert(up.min.y > 2.3,
`intact rafters bottom out at y=${up.min.y.toFixed(2)} — the control is broken`);
// The wreck does not stand taller, and the DECK did not move: it is the
// footing the rating is about, and the shared helper builds it once —
// byte-drift between the twins' decks means someone forked the helper.
const hi = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene);
const hw = new THREE.Box3().setFromObject(w.scene);
assert(hw.max.y <= hi.max.y + 0.02,
`the wreck stands ${hw.max.y.toFixed(2)} m vs the intact ${hi.max.y.toFixed(2)} — taller is a bug`);
const deckA = new THREE.Box3().setFromObject(loaded.get('pergola_01').scene.getObjectByName('deck'));
const deckB = new THREE.Box3().setFromObject(w.scene.getObjectByName('deck'));
assert(deckA.min.distanceTo(deckB.min) < 0.01 && deckA.max.distanceTo(deckB.max) < 0.01,
'the two decks differ — intact and wrecked no longer share the helper, and they will drift');
});
// The glasshouse's one rule, pinned from every direction: NOTHING about it
// may adopt as an anchor. Not the ridge (denied in words), not any node by
// silence (a rating_hint anywhere on either variant is a red), not via the
// enum (no node enrols an anchor_type). The ridge bar is the most
// tie-off-shaped object in the game and that is exactly why the denial is
// load-bearing rather than decorative.
t.test('the glasshouse offers NOTHING: no rated node, no typed node, and the ridge denies', () => {
for (const name of ['glasshouse_01', 'glasshouse_01_wrecked']) {
const g = loaded.get(name);
assert(g, `${name} did not load`);
const rated = [], typed = [];
g.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number') rated.push(o.name);
if (o.userData?.anchor_type !== undefined) typed.push(o.name);
});
assert(rated.length === 0,
`${name} carries rating_hint on [${rated.join(', ')}] — a glasshouse node with a rating `
+ 'is adoptable, and 30 kg of glass is not a thing you add load to');
assert(typed.length === 0,
`${name} enrols [${typed.join(', ')}] in the anchor enum — nothing here is an anchor of any type`);
}
const ridge = loaded.get('glasshouse_01').scene.getObjectByName('ridge_anchor');
assert(ridge, 'ridge_anchor missing — the temptation must exist to be denied');
assert(ridge.userData?.tie_off === false,
'the ridge must carry tie_off:false — the most anchor-looking member says no ON the data');
assert(typeof ridge.userData?.why === 'string' && ridge.userData.why.length > 0,
'the denial must carry its reasoning — a bare flag is a rule, a why is a lesson');
});
// The apex is a price claim, so it is pinned as one: strictly the worst
// bill in the palette, keyed, and identical on the wreck. The billing
// EVENT is arc 2's (nothing in the sim wrecks a glasshouse yet — the value
// is inert data until then, which is why pricing it is not the bike bug);
// this pin is the chain being ready the day that yard is authored.
t.test('the glasshouse is the collateral apex: priced above everything, keyed, twin-priced', () => {
const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01');
assert(g, 'glasshouse_01 root missing');
const cost = g.userData?.collateral_value;
assert(typeof cost === 'number', 'glasshouse carries no collateral_value');
assert(g.userData?.collateral_key === 'glasshouse',
'collateral_key must be explicit — the carport was one editor click from being FREE '
+ 'because its price resolved off a coincidence of naming');
const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value;
assert(cost > carport,
`the glasshouse (${cost}) must top the carport (${carport}) by construction — fourteen panes `
+ 'of glass against one sheet of Colorbond, or "apex" is just a word');
const w = loaded.get('glasshouse_01_wrecked')?.scene.getObjectByName('glasshouse_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked glasshouse must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'glasshouse_01',
'the wreck must name its intact twin so A can pair the swap');
});
// What glass DOES, measured: the intact panes are translucent (the fragile
// read is a material fact, not a vibe — turn the glass opaque and the tell
// is deleted), and the wreck EMPTIES rather than falls (frame at full
// height, shard field flat on the ground and thrown wider than the
// building ever stood).
t.test('the glasshouse reads fragile, and its wreck empties instead of falling', () => {
const panes = loaded.get('glasshouse_01')?.scene.getObjectByName('panes');
assert(panes, 'panes node missing');
let translucent = false;
panes.traverse((o) => {
const m = o.material;
if (m && m.transparent === true && m.opacity < 0.6) translucent = true;
});
assert(translucent,
'no pane material is transparent with opacity < 0.6 — the glass has gone opaque, and '
+ 'the only see-through walls in the palette no longer read as glass');
const hi = new THREE.Box3().setFromObject(loaded.get('glasshouse_01').scene);
const wreck = loaded.get('glasshouse_01_wrecked');
const hw = new THREE.Box3().setFromObject(wreck.scene);
assert(Math.abs(hi.max.y - hw.max.y) < 0.05,
`wreck ridge at y=${hw.max.y.toFixed(2)} vs intact ${hi.max.y.toFixed(2)} — the aluminium frame `
+ 'survives; a felled glasshouse is lying about what glass does');
const shards = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('shards'));
assert(shards.max.y < 0.05,
`shards top out at y=${shards.max.y.toFixed(2)} — glass on the ground lies flat`);
const spreadX = (hw.max.x - hw.min.x) - (hi.max.x - hi.min.x);
const spreadZ = (hw.max.z - hw.min.z) - (hi.max.z - hi.min.z);
assert(spreadX > 0.6 && spreadZ > 0.6,
`the shard field adds only ${spreadX.toFixed(2)}/${spreadZ.toFixed(2)} m in plan — it must `
+ 'throw glass clear of the footprint or the wreck reads as a dirty window, not a catastrophe');
assert(wreck.scene.getObjectByName('panes_left'),
'panes_left missing — a wreck with zero surviving panes reads as demolition, not storm');
});
// The pool ring: fourteen posts, fourteen explicit nos. RULE 1's regex only
// guards *_anchor names, and these posts don't match it — which is exactly
// how they'd slip through as silent 1.0s the day a site names one (the
// `?? 1` default is generous, and silence is not neutral). So the denial is
// pinned per-post, by name, with the count exact: a fifteenth post added
// without its denial goes red here, not in a shipped yard.
t.test('the pool ring is fourteen honest nos — every fence post denies, nothing rates', () => {
const g = loaded.get('pool_kit_01');
assert(g, 'pool_kit_01 did not load');
const posts = [];
g.scene.traverse((o) => { if (/^fence_post_\d+$/.test(o.name)) posts.push(o); });
assert(posts.length === 14,
`found ${posts.length} fence posts, want exactly 14 — a post this count doesn't know `
+ 'is a post whose denial nobody checked');
for (const p of posts) {
assert(p.userData?.tie_off === false,
`${p.name} does not deny being a tie-off — silence here adopts at rating 1.0, the best `
+ 'steel in the game conjured out of a missing field');
assert(p.userData?.rating_hint === undefined,
`${p.name} carries a rating_hint, which makes it adoptable — the ring's whole design is `
+ 'that its steel is legally useless');
assert(typeof p.userData?.why === 'string' && p.userData.why.length > 0,
`${p.name} denies without reasoning`);
}
// Nothing anywhere on the kit rates or enrols — same sweep as the glasshouse.
const rated = [], typed = [];
g.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number') rated.push(o.name);
if (o.userData?.anchor_type !== undefined) typed.push(o.name);
});
assert(rated.length === 0 && typed.length === 0,
`pool_kit_01 offers [${[...rated, ...typed].join(', ')}] — the kit must offer nothing`);
// Compliance is the flavour, so its numbers are pinned as facts: 1.2 m
// posts, a self-closing gate, and no price until something can break it.
const post1 = new THREE.Box3().setFromObject(g.scene.getObjectByName('fence_post_01'));
assert(post1.max.y >= 1.15 && post1.max.y <= 1.32,
`fence stands ${post1.max.y.toFixed(2)} m — AS 1926.1 says 1.2, and the height IS the flavour`);
const gate = g.scene.getObjectByName('pool_gate');
assert(gate?.userData?.self_closing === true, 'the gate must be self-closing — compliance is data');
const root = g.scene.getObjectByName('pool_kit_01');
assert(root?.userData?.priced === false && typeof root?.userData?.unpriced_why === 'string',
'the kit must say OUT LOUD that it is unpriced and why (the bike ruling) — an unpriced '
+ 'prop with no reason is indistinguishable from a forgotten one');
assert(root?.userData?.collateral_value === undefined,
'pool_kit_01 carries a collateral_value — pricing an event the sim cannot produce is the '
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {

View File

@ -14,6 +14,7 @@ import {
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js';
import { makeStorm, checkEnvelope, ENVELOPES } from '../stormgen.js';
const DT = 1 / 60;
@ -44,7 +45,14 @@ export function weatherCases(storms) {
const test = (name, fn) => cases.push({ name, fn });
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const defs = Object.entries(storms);
// SPRINT16 gate 3.2: two generated storms ride the SAME per-storm honesty
// gates as the authored ones below (telegraph lead, continuity, validator).
// A family whose members can be dishonest is not an envelope, it's a slot
// machine — fixed seeds so the suite stays deterministic run to run.
const defs = [
...Object.entries(storms),
...[11, 47].map((s) => [`gen_soaker_${s}`, makeStorm(s)]),
];
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
@ -707,6 +715,7 @@ export function weatherCases(storms) {
inside(f.gustPeak, s.gustPeak, 'gustPeak');
inside(f.rain, s.rainPeak, 'rain');
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
inside(f.hail.size, s.hailSize, 'hailSize'); // gate 3.3: the stone is a band too
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
}
}
@ -814,6 +823,84 @@ export function weatherCases(storms) {
assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
});
// ---- 14. makeStorm(seed) — SPRINT16 gate 3.2, arc 4's groundwork ----
// A seeded generator inside authored envelopes. No shipped night uses it;
// these cases are what "inside" and "deterministic" MEAN.
test('makeStorm: same seed → byte-equal def; different seed → different storm', () => {
const a = JSON.stringify(makeStorm(1234));
const b = JSON.stringify(makeStorm(1234));
assert(a === b, 'two calls with the same seed produced different JSON — a hidden draw or a Date leaked in');
const c = JSON.stringify(makeStorm(1235));
assert(a !== c, 'seeds 1234 and 1235 produced the same storm — the seed is not reaching the draws');
// the def carries its seed, so the gust timeline downstream is seeded by
// the same number the caller noted down
assert(makeStorm(1234).seed === 1234, 'generated def does not carry its seed');
});
test('makeStorm: 100 seeds — every def validates AND sits inside its envelope', () => {
let gustLo = Infinity, gustHi = 0;
for (let s = 1; s <= 100; s++) {
const d = makeStorm(s);
const v = validateStorm(d, `gen_${s}`);
assert(v.ok, `seed ${s} failed the storm validator:\n ${v.errors.join('\n ')}`);
const e = checkEnvelope(d);
assert(e.ok, `seed ${s} escaped its envelope:\n ${e.errors.join('\n ')}`);
const st = stormStats(d);
gustLo = Math.min(gustLo, st.gustPeak); gustHi = Math.max(gustHi, st.gustPeak);
assert(st.hailSeconds > 2, `seed ${s} barely hails (${st.hailSeconds.toFixed(1)} s) — the family's thesis is pea hail all night`);
assert(d.hail.size < 0.6, `seed ${s} threw size-${d.hail.size} stones — over the family's pea range, cloth would block them`);
}
metrics['stormgen.gustPeak.lo'] = +gustLo.toFixed(1);
metrics['stormgen.gustPeak.hi'] = +gustHi.toFixed(1);
// the family is genuinely a FAMILY: seeds move the storm, inside a band
assert(gustHi - gustLo > 1, `100 seeds moved gustPeak only ${(gustHi - gustLo).toFixed(2)} m/s — the ranges are decoration`);
assert(gustHi < 20, `a soaker gusted to ${gustHi.toFixed(1)} m/s — the family has stopped being mild`);
});
test('checkEnvelope: a def outside the envelope goes red, not shrugged', () => {
// vacuity guard first: the def we are about to mutate passes as generated
const clean = makeStorm(7);
assert(checkEnvelope(clean).ok, 'the unmutated control failed — every red below would be noise');
// each mutation is one escape route, and each must be NAMED in the errors
const mutations = [
['base speed over the family ceiling', (d) => { d.baseCurve[3][1] = 12; }, /baseCurve\[3\]/],
['pea family throwing ice', (d) => { d.hail.size = 1.3; }, /hail\.size/],
['gusts overlapping (minGap under the gust length)', (d) => { d.gusts.minGap = 4; }, /minGap/],
['the change re-timed outside its window', (d) => { d.events[0].t = 20; d.dirCurve = [[0, 0.6], [16, 0.7], [28, -0.7], [90, -0.65]]; }, /change at t=20/],
];
for (const [label, mutate, want] of mutations) {
const d = makeStorm(7);
mutate(d);
const e = checkEnvelope(d);
assert(!e.ok, `checkEnvelope shrugged at: ${label}`);
assert(e.errors.some((m) => want.test(m)),
`${label}: red, but no error names the escape — got:\n ${e.errors.join('\n ')}`);
}
// and the one that separates checkEnvelope from validateStorm: a def that
// is a perfectly LOADABLE storm but is not a member of this family
const stranger = makeStorm(7);
stranger.rain.peakMmPerHour = 75; // a severe soaker — valid, off-family
assert(validateStorm(stranger, 'stranger').ok, 'the stranger should still validate as a storm');
assert(!checkEnvelope(stranger).ok, 'a valid-but-off-family storm passed the envelope — checkEnvelope is just the validator in a hat');
});
test('makeStorm: the change event and the physics agree by construction', () => {
// the validator's windchange/dirCurve drift trap, proven closed for every
// seed we ship gates on — the event's promised swing is real in dirCurve
for (const s of [3, 21, 88]) {
const d = makeStorm(s);
const ev = d.events.find((e) => e.type === 'windchange');
const f = createWindField(d);
const before = f.dirAt(ev.t - 6);
const after = f.dirAt(ev.t + (ev.over ?? 6) + 6);
const swing = Math.abs(after - before);
assert(swing > 0.6, `seed ${s}: the change at t=${ev.t} only swung ${swing.toFixed(2)} rad`);
}
});
return { cases, metrics };
}

View File

@ -638,7 +638,10 @@ export function stormStats(def) {
const stats = {
sustained, gustPeak, rainPeak,
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
hailPeak, hailSeconds,
// Stone size (SPRINT16 gate 3.3): the fabric bet argues on the forecast,
// and the stone is the argument — a pea leaks through cloth, ice doesn't.
// 0 when the storm never hails, matching hailSeconds' convention.
hailPeak, hailSeconds, hailSize: f.hailSize,
changeAt: change ? change.t : null,
};
_statsCache.set(def, stats);
@ -648,6 +651,9 @@ export function stormStats(def) {
/** How wide each number's band runs at lead=1, as a fraction of the value. */
const FORECAST_SPREAD = {
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
// Stone size hedges less than duration: radar sees how big the cell is long
// before it knows how long it will sit on you.
hailSize: 0.30,
};
/**
@ -692,7 +698,14 @@ export function forecastFor(def, lead = 0) {
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
hail: {
chance,
seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds),
// SPRINT16 gate 3.3 — the stone joins the card. Band, not a promise:
// contains the truth at every lead, exact at lead 0 (band() guarantees
// both). {lo:0, hi:0} for a storm that never hails.
size: band(s.hailSize, FORECAST_SPREAD.hailSize),
},
truth: s,
};
}

View File

@ -84,11 +84,32 @@ export function forecastLines(def, lead = 0) {
: '· no change forecast';
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
// SPRINT16 gate 3.3 — the stone and the rain RATE join the card (A prints
// them; this owns the wording, same split as every other line here). The
// stone is the fabric bet's whole argument: pea hail rattles through a
// 2 mm weave, anything bigger cannot — so the size word is what tells a
// player which fabric tonight wants, before a dollar is spent. Words map
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
// own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3
// and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead.
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
const sz = f.hail.size;
const stones = f.hail.chance === 'none' ? '' : (
stoneWord(sz.lo) === stoneWord(sz.hi)
? `${stoneWord(sz.hi)} stones`
: `${stoneWord(sz.lo)}${stoneWord(sz.hi)} stones`
);
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(),
// The gate-3.3 lines, separate fields so A's card can place them without
// re-deriving anything. Empty string = nothing to print, same rule as
// `confidence` below; both bands contain the truth at every lead.
rainRate: `rain to ${band(f.rainMmPerHour, i)} mm/hr`,
stones,
// 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,

View File

@ -171,6 +171,11 @@ export const PAY = {
/**
* Nothing of theirs broken (SPRINT11 gate 2 the third line on the invoice).
*
* SPRINT16 note: `noCollateralBonus` is no longer the only unmeasured number
* here REP below joined it, with the same rule and the same cut-first
* status. This one keeps seniority: it's pure new money every night, where
* REP's swing is a percentage of a fee you had to earn.
*
* It exists to give the schedule three axes that mean three different things,
* which is what turns a scoreboard into an invoice:
* base you turned up and did the job (severity-scaled; less if it went wrong)
@ -199,6 +204,54 @@ export const PAY = {
noCollateralBonus: 20,
};
/**
* SPRINT16 gate 1 REPUTATION. One number on the letterhead, and it moves pay.
*
* The unit is STARS, 0..5 in half-star steps, neutral start at 3.0 ruled in
* THREADS [A] 2026-07-20: stars because the spec asks for client-speak, and
* half-steps because every number the letterhead prints stays exact (no float
* drift on a business document).
*
* Movement is settled at DAWN OF THE NIGHT THAT EARNED IT, not the morning the
* warranty line books the rep and the warranty item come from the same dawn
* inspection of the same rig record, they just surface one morning apart:
* · clean night (zero breaks, zero collateral): +CLEAN
* · each corner of yours still broken at dawn: WARRANTY (it books tomorrow)
* · each collateral event: COLLATERAL (down harder)
* A broke-but-repaired-by-dawn night is NEUTRAL by design: not clean (it
* broke), not a warranty (you fixed it mid-storm on your own ladder that IS
* the job). And rep NEVER reads the garden hp/win/gardenBeyondSaving are not
* inputs, so night 5's designed loss cannot cost rep by construction rather
* than by exemption. a.test pins that from both directions.
*
* Money: `multiplier(rep)` scales the callout fee at QUOTE TIME quote() and
* settle() both read rep-as-of-morning, and rep only updates after settle has
* computed the fee, so the sheet and the invoice agree by ordering, not by
* discipline. 0.04/star is chosen over the rounder-sounding 0.05 because every
* half-step lands exactly on two decimals (×0.88 .. ×1.08) the sheet prints
* the multiplier, and a printed number that isn't the number used is the
* quote-vs-settle lie wearing smaller type.
*
* UNMEASURED, same status as noCollateralBonus above: the swing is bounded
* (±12/8% of fee $6..9 a night) so it cannot mint a runaway week by itself,
* but nobody has played six nights under it yet. If gate 6's play says the
* economy tilts, CLEAN and the multiplier slope are levers, cut second after
* the clean bonus.
*/
export const REP = {
START: 3.0,
MIN: 0,
MAX: 5,
/** Clean night: no breaks (repaired or not), nothing of theirs broken. */
CLEAN: 0.5,
/** Per corner of yours still broken at dawn — the warranty ledger's rep half. */
WARRANTY: -0.5,
/** Per collateral event. Down harder: their property beats your hardware. */
COLLATERAL: -1.0,
/** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
multiplier: (r) => 1 + 0.04 * (r - 3),
};
/**
* Below this you cannot rig four corners at all four carabiners is the floor.
*
@ -257,10 +310,19 @@ export function gustPeakOf(def) {
* @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} pay fee + bonus + refund collateral warrantyTotal
* @property {number} bankBefore
* @property {number} bankAfter
* @property {'continue'|'win'|'gameover'} outcome
* @property {object[]} rig SPRINT16 the work history: one entry per corner
* of the committed rig at dawn {anchorId, hw, hwCost, effRating, broke,
* repaired, brokenAtDawn}. main.js's scoreRun assembles it; the week keeps it.
* @property {object[]} warranty SPRINT16 the items deducted TONIGHT (booked by
* last dawn's brokenAtDawn corners) {anchorId, hw, cost, night, site, addr, client}
* @property {number} warrantyTotal
* @property {number} feeMult rep multiplier the fee was priced at
* @property {number} rep after tonight's movement (repBefore/repDelta/repReasons too)
* @property {number} warrantiesToDate cumulative items this week, for the end card
*/
/**
@ -272,9 +334,32 @@ export function createWeek(opts = {}) {
let index = 0; // 0-based into NIGHTS
let bank = startBank;
let done = false;
let rep = REP.START; // SPRINT16 — the number on the letterhead
/** @type {Settlement[]} */
const log = [];
/**
* Tonight's warranty items corners of YOURS the last dawn found still
* broken. Derived from the previous settlement's rig record, never stored a
* second time: quote() and settle() both call THIS, so the sheet and the
* invoice read the same items by construction (the week's own invariant
* they never diverge extended to the new line the same way the gardenMax
* comment in settle() demands).
*
* `cost` is the broken hardware's shop price. The labour is free ON PURPOSE
* "no charge to the client" is the whole sentence, and unpaid labour is
* what a warranty IS. A mid-storm repair books nothing (repaired corners
* are not in the filter); riding it out broken is the item.
*/
function warrantyItems() {
const prev = log[log.length - 1];
if (!prev) return [];
return (prev.rig ?? []).filter((c) => c.brokenAtDawn).map((c) => ({
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
}));
}
const week = {
/** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; },
@ -299,10 +384,22 @@ export function createWeek(opts = {}) {
*/
quote(def) {
const j = nightAt(index);
// SPRINT16: the fee is priced on your standing, and the deduction for
// last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
// pays exactly what the sheet said. Both read the same sources settle
// does (rep before tonight moves it; warrantyItems() off the log), which
// is the invariant doing the work, not a matched pair of formulas.
const feeMult = REP.multiplier(rep);
const warranty = warrantyItems();
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
return {
base: PAY.feeFor(gustPeakOf(def)),
base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult),
garden: j.pay.garden ?? PAY.gardenBonusMax,
clean: j.pay.clean ?? PAY.noCollateralBonus,
feeMult,
rep,
warranty,
warrantyTotal,
/**
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
* to say when the garden bonus is money the night cannot pay.
@ -320,16 +417,22 @@ export function createWeek(opts = {}) {
* still not telling it.
*/
beyondSaving: j.gardenBeyondSaving ?? false,
get total() { return this.base + this.garden + this.clean; },
// The quote's conclusion carries the warranty deduction — the sheet
// promises the deducted number, and settle pays it. A quote that hid
// the deduction until the invoice would be the divergence in reverse.
get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
};
},
get bank() { return bank; },
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
get rep() { return rep; },
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;
rep = REP.START;
return week;
},
@ -343,7 +446,12 @@ export function createWeek(opts = {}) {
*/
settle(run, def, spent) {
const bankBefore = bank;
const fullFee = PAY.feeFor(gustPeakOf(def));
// SPRINT16: rep prices the fee, and it MUST be tonight's morning rep —
// the one the quote printed. rep is updated at the bottom of this
// function, after the money is computed, so quote and settle agree by
// ordering. Round after the multiplier, exactly as quote() does.
const feeMult = REP.multiplier(rep);
const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult);
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
// Per-job override, PAY's default otherwise — and settle MUST read the same
// source the quote does, or the job sheet promises a number the invoice
@ -363,7 +471,13 @@ export function createWeek(opts = {}) {
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
const clean = collateral === 0 ? cleanMax : 0;
const pay = fee + bonus + refund + clean - collateral;
// SPRINT16 — warranty. The SAME derivation the quote used (same function,
// same log state — the log only grows at the bottom of this call), so the
// deduction the sheet promised is the deduction the invoice takes.
const warranty = warrantyItems();
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
const pay = fee + bonus + refund + clean - collateral - warrantyTotal;
bank = Math.max(0, bankBefore - spent + pay);
@ -385,6 +499,41 @@ export function createWeek(opts = {}) {
else outcome = 'continue';
done = outcome !== 'continue';
// SPRINT16 — reputation moves at dawn of the night that earned it (the
// THREADS ruling): the same dawn inspection that will book tomorrow's
// warranty lines moves the number tonight. NOTE what is absent: hp, win,
// gardenBeyondSaving. Rep never reads the garden, so a designed loss
// cannot cost it BY CONSTRUCTION — the flag needs no exemption here, and
// a.test pins that from both directions.
const rigRec = run.rigRecord ?? [];
const brokeCount = rigRec.filter((c) => c.broke).length;
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn).length;
const collateralEvents = (run.collateral ?? []).length;
const repBefore = rep;
let repDelta = 0;
/** @type {string[]} */
const repReasons = [];
if (brokeCount === 0 && collateralEvents === 0) {
repDelta += REP.CLEAN;
repReasons.push('clean night');
} else if (dawnBroken === 0 && collateralEvents === 0) {
// Broke, but every corner was standing again by dawn: NEUTRAL, and the
// invoice's rep line says why rather than printing an unexplained flat
// number. Fixing your own steel mid-storm is the job — it just isn't a
// clean night, and it isn't a warranty either.
repReasons.push('broke, but repaired by dawn');
}
if (dawnBroken > 0) {
repDelta += REP.WARRANTY * dawnBroken;
repReasons.push(`${dawnBroken} corner${dawnBroken > 1 ? 's' : ''} down at dawn`);
}
if (collateralEvents > 0) {
repDelta += REP.COLLATERAL * collateralEvents;
repReasons.push(`broke ${collateralEvents > 1 ? 'things' : 'something'} of theirs`);
}
rep = Math.min(REP.MAX, Math.max(REP.MIN, rep + repDelta));
repDelta = rep - repBefore; // what actually moved, after the clamp
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
@ -393,7 +542,18 @@ export function createWeek(opts = {}) {
// settlement so the HUD never has to reach back into week state.
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
gardenMax,
client: job.client, addr: job.addr, site: job.site };
client: job.client, addr: job.addr, site: job.site,
// SPRINT16 — the ledger. The rig record IS the work history (the night
// computed all of it; the week just keeps it), the warranty items are
// tonight's sheet answered on the invoice, and the rep block is the
// letterhead's number with its receipts. warrantiesToDate feeds the
// end card's rich-with-warranties reading without the HUD walking the
// log itself.
rig: rigRec,
warranty, warrantyTotal, feeMult,
rep, repBefore, repDelta, repReasons,
warrantiesToDate: log.reduce((n, x) => n + (x.warranty?.length ?? 0), 0)
+ warranty.length };
log.push(s);
return s;
},

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.