Compare commits

...

18 Commits

Author SHA1 Message Date
type-two
f27539d716 Sprint 16 (TEETH) integration: merge lane/d (473/0/0), [I] entry — six nights live 2026-07-20 22:32:48 +10:00
type-two
f53a0d5ee9 Lane D S16: [D] THREADS — gate 4.1/4.2 wiring + eviction filing, gate 4.3 cold-play verdicts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:23:11 +10:00
type-two
d8e7087c2c Lane D S16 gate 4.1+4.2: swing lawn re-scored (zero deltas), THE SIX-NIGHT WEEK wired
- site_03 re-scored on the post-ruling tree, S15 loop, FUNNEL ON: every number
  reproduces the S14 card to the digit (81 quads, 11 clean/16 marginal, cheapest
  honest $65 h3,t2,t2b,p1 85.3 FULL, bare 83.7, best clean 90.0, sep +6.3,
  best-reading 91.8 MARGINAL). Nothing moved; nothing to fix; B's PIN REFUSED
  (measured) card renders the finding on this yard.
- NIGHTS is six: gentle, southerly, earlybuster(s02), SOUTHERLY over the SWING
  LAWN (new site, survived storm), icenight (flag intact, no longer final),
  SOAKER over the CORNER BLOCK (C's measured pairing). Two new briefs: Ruby's
  swing set sells the crossbar trap; the Vasilaros return sells the cloth
  default the forecast argues against.
- THE STRUCTURAL CONSEQUENCE, filed loudly: six slots cannot hold five old
  storms + a survived-storm debut + the soaker. The WILD NIGHT leaves the
  rotation (every other candidate is load-bearing); storm file, tests and the
  pinned backyard separation all stand. If the eviction is wrong the fix is a
  seven-night week — one entry, a ruling.
- week asserts updated (a.test): six nights, final-night=6 both directions on
  the broke rule, three clients with night 6 a RETURN booking.
- four six-night-law pins in d.test: one new site / one new storm / never same
  night; survived-storm debut; soaker pairing (C's receipt in the message);
  beyond-saving night neither new nor final.
- hud surfaces read NIGHTS.length (splash, morning header, end card) — flagged
  for A; hardcoded 'five' would lie on every paper.
- selftest 473/0/0 (469 + 4); mutations M1 soaker→backyard, M2 debut→wildnight,
  M3 flag→final each RED with the law's own words, restored, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:49:58 +10:00
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
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
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
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
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
26 changed files with 3301 additions and 77 deletions

View File

@ -7383,6 +7383,470 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
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 —
@ -7557,3 +8021,180 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
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.
[D] 2026-07-20 — 🗓 **GATE 4.1 + 4.2 — RE-SCORE ZERO-DELTA, THE SIX-NIGHT WEEK IS WIRED, AND THE
ARITHMETIC EVICTED THE WILD NIGHT. John, A: the eviction is the entry's headline and it is
yours to overturn — one line puts it back as a seventh night.** Selftest 473/0/0 (469 + my 4
law pins), three mutations red-then-green, everything below verified in the browser on :8827.
**4.1 — the swing lawn re-score, S15 loop, post-ruling tree, FUNNEL ON:** every number on the
S14 card reproduced TO THE DIGIT — winnable at $80 · 81 quads · 11 clean / 16 marginal ·
cheapest honest h3,t2,t2b,p1 $65 (holds $55) · 22 m² · 85.3 FULL · bare 83.7 → best clean
h3,t1c,t2,p1 90.0, sep +6.3 · best-reading t1c,t2,t2b,s1_f2 still MARGINAL at 91.8 (the trap
line, still flagged as such). B predicted zero movement from their gate-2.3 probe and the
prediction held — nothing to fix, nothing retuned. B's PIN REFUSED (measured) card renders the
site's _separation_finding correctly on this yard; the finding block is carried, not touched.
**4.2 — the ladder as wired (week.js, D's edits by the sprint's explicit grant):**
· N1 gentle × backyard (Hendersons) — unchanged
· N2 southerly × backyard (Hendersons) — unchanged
· N3 earlybuster × site_02 (the Vasilaros place) — unchanged
· **N4 southerly × site_03_swing_lawn (the Delaneys)** — THE NEW SITE, under Tuesday's storm:
at its debut the yard is the only new variable, C's "southerly or buster" taken at southerly
because every measured number on the yard (S14 card, my 4.1 re-score, B's probe) is a
southerly number.
· N5 icenight × backyard (Hendersons, gardenBeyondSaving intact) — NO LONGER FINAL, which
quietly closes the gap A filed at gate-1 landing: a night-5 dawn break now has a morning to
book its warranty on. Brief reworded ("The most wind of the week and golf-ball ice") because
"less wind than last night" compared against the wild night that no longer precedes it.
· **N6 storm_06_soaker × site_02 (the Vasilaros place, a RETURN booking)** — THE NEW STORM,
over the yard night 3 taught, C's measured pairing honoured. Final night, so
going-broke-never-on-the-final-night now guards the fabric-bet night.
**The two new briefs** (client flavour is data): Ruby Delaney's dad sells the crossbar trap in
his own voice ("bolted right through, tie to that and save yourself a post" — the crossbar is
NOT an anchor and the apexes are 0.45 steel billing $140), and the bed has its reason ("her
school project, so it comes home alive"). The Vasilaros return sells the WRONG DEFAULT the way
"plenty to tie off to" sold the carport: "the kind of night you could rig in your sleep.
Whatever you flew last time will do, they said." — last time was cloth; the forecast's own
lines (C's stones/rainRate) are what argue back.
**⚠️ THE EVICTION, said before anyone finds it in a diff:** six slots cannot hold seven
nights. Five shipped storms + the soaker = six storms; the swing lawn's debut must REPEAT an
already-survived storm (gate 4.2's own words, and C read it the same way); so exactly one
shipped storm leaves the week. Every other candidate is load-bearing — gentle (tutorial +
calm-day preload), southerly (hail intro + the buster's "same as Tuesday" referent + the swing
lawn's debut partner), earlybuster (site_02's debut, which the soaker pairing needs), icenight
(the flag, canon). **The WILD NIGHT is out of the rotation.** Its storm file, its tests, C's
forecast word map, B's membrane table and the pinned backyard separation all stand — it is
un-flown by NIGHTS, not deleted. What the week loses: its 30-gust apex and the one shipped
night where a cloth rig buys real garden (+28.1 HP, B's table). What it gains: the soaker as
the new teeth night (bare 17.5 DEAD → membrane 96.5 FULL — a Δ79 the wild night never
offered). If the trade is wrong, the fix is a SEVEN-night week (arc 2's own range is
"five-to-seven") — one NIGHTS entry re-added, a ruling, not a refactor. **Gate 6 material.**
· The week asserts moved exactly as the sprint predicted (a.test: six nights, final-night
walk, three clients with N6 pinned as a RETURN of N3's client, broke-on-5 = gameover both
directions). Four new SIX-NIGHT LAW pins live in d.test next to their owner: one new site /
one new storm / never the same night · new-site debut only under an earlier night's storm ·
soaker only over site_02 with C's receipt in the failure message · the beyond-saving night
neither new nor final. Mutations, each red with the law's own words, restored: soaker →
backyard (2 red), debut → wildnight (2 red), flag → final night (2 red).
· **A, your files, my edits, flagged:** hud.js now reads NIGHTS.length for the splash
("Six nights, six jobs"), the morning header (NIGHT n OF 6) and the end card (n/6 rows,
count-worded copy) — hardcoded "five" would have been the paper lying on every surface;
word map falls back to digits for an unmapped length. a.test's week suite updated as above.
Revert-and-tell-me standard applies.
· **A, one observation, not a change:** gradeFor(5)='clean' now lands on the six-night week's
BEST POSSIBLE outcome (5 won + the designed loss), so THE WEEK HELD is finally reachable —
played it tonight, it reads earned. That is thresholds coinciding, not design: a seven-night
week would need gradeFor re-ruled in the same commit.
[D] 2026-07-20 — 🎮 **GATE 4.3 — THE COLD-PLAY: SIX NIGHTS, ONE WALLET, REAL UI, AND A VERDICT
FOR EVERY GATE. Short version: TEETH works — the ledger reads FAIR at every surface it
touches, the fabric bet is legible from the paper, and the week tells a story it could not
tell five nights ago.** Wallet trace: $80 → 174 → 242 → 311 → 366 → 361 → **$419**, end card
THE WEEK HELD, 6/6 nights, 5/6 gardens, ★3.0, 2 warranty jobs booked. Storms fast-forwarded
with the game's own fixed-dt step (the disclosed gait); every card read at the moment it was
live; night 6's broom run driven through the real interact chain (real key events, real
holds).
**GATE 1 (A) — the ledger: FAIR, and twice it was fair about ME.**
· N1: my $5 carabiner on P5 let go ON THE SEA BREEZE (peak wind ~11 m/s beats 1200 N steel on
the windward corner — the tutorial's "nothing can hurt you" is true of the GARDEN, not of a
$5 corner; that reads as the shop teaching, not a lie, but noting it for the record). I rode
it out broken: ★3.0 → ★2.5 at dawn with the reason printed, and next morning's sheet read
"warranty — P5, carabiner, no charge to the client · $5 · your corner, down at dawn — 14
Kurrajong St". I watched that corner hang all night; the line felt like a receipt, not a
fine. **Warranty callout on your sheet: FAIR.**
· N3→N4 is the ruling working cross-client: Q2's shackle (corner block, pond corner, let go
late — C called that corner in their gate-3.1 entry) booked on the DELANEYS' sheet with the
Vasilaros address on the line. The van detouring on the way to the job reads exactly as the
ruling intended. quote==settle held every morning it was tested, deduction on both papers.
· **Rep moving pay: EARNED.** ×0.98 on the fee row the mornings after my two dawn breaks,
×1.02 on night 6 after the icenight's clean +0.5 — the multiplier only ever appeared WITH
its reason line, and the swing (a few dollars) is felt as reputation, not as balance. The
night-5 pin is the one that matters and it held IN PLAY: garden 38%, night lost, rep UP
(★3.0 → ★3.5, "clean night") with the verdict crediting the steel — "Yours held anyway.
That was the whole job tonight, and you did it." A designed loss that pays respect instead
of docking it. **Gate 1: PASS, no retune asked.**
**GATE 2 (B) — the heal, the paper trail, the pin refusals.**
· fabricNote: fired FIRST on night 4 of all places — cloth on the southerly with 9 HP of fine
hail through: "stones this fine rattle through the weave... The membrane stops every stone."
Honest per its thresholds (B's table says membrane was RIGHT on the southerly by +1.7), and
pedagogically it is the whole reason night 6 was legible — see gate 3. N6's membrane note
("held 391 kg of what it stopped. That is the membrane's price") lands the trade. Silent on
the nights it would have been a guess. **The line fires when the bet mattered: PASS.**
· The heal: six honest nights produced ZERO divergences — that datum first. I then poisoned a
mid-cloth node on a scratch night (disclosed poke, B's own gait): the NaN cascade broke all
four corners inside a step, the sail vanished clean from the render — reads as "the sail's
gone", a break, NOT a glitch; garden HUD flipped to bare-bed honestly. **One edge for B's
file, observed not alarmed:** with all four corners already divergence-broken, a SECOND
poison has no unbroken corner left to arm the heal (_checkFailure skips broken corners), so
a fully-lost sail that diverges again stays NaN and renders as nothing. Player-facing
surface is acceptable (a lost sail LOOKING lost); noting it because your "NaN pond second
wire" comment lives one door down and the day a free sheet can re-diverge, the heal has no
trigger there. Your in-suite poison assert (green in my 473) covers the partial case that
actually matters.
· The pin refusals: B's PIN REFUSED (measured) card on both my yards prints the finding where
the author will look. Correct surface, nothing to add.
**GATE 3 (C) — storm_06 and the forecast: THE BET IS LEGIBLE, and here is the receipt as a
played sequence.** N6's sheet: "sustained to 9 m/s · gusts ~55 · rain heavy · FINE PEA STONES
· rain to 44 mm/hr" over a brief telling me to fly what I flew last time. Cold read at the
sheet: mildest wind since night 1 + fine stones + heavy rain, and night 4's invoice had
already taught what fine stones do to a weave → pressed F before spending a dollar. Membrane
+ C's $75 rated-q1 ring: garden 96% where every measured cloth line on the night reads DEAD.
**The forecast sells the bet — BUT the chain matters: "fine pea stones" carries the answer
because B's fabricnote taught "fine = through the weave" two nights earlier. Forecast words +
paper trail TOGETHER are the legibility; either alone is a puzzle. In the shipped order the
chain works. PASS.**
· C's q2 call was surgical: I broomed the pond LATE (t≈65 on, real broom, real holds) and q2
let go at ~t80 anyway — the broom's job on that corner starts when the rain does, not when
the belly shows. The corner-block pond punishing the same corner two different nights (N3
cloth, N6 membrane) is the yard having a personality; keep it.
· Final-night dawn breaks: q2 down at dawn on N6 cost the rep (★3.5 → ★3.0, printed) and
booked NOTHING — no morning left. A filed this at gate-1 landing as arc-2's warranty-callout
seed; the six-night order moved the beyond-saving night off the final slot, so the only
breaks that can now vanish from the ledger are night-SIX breaks. Same filing, smaller hole,
arc 2 closes it.
**THE ONE SURFACE QUESTION TO FILE (C predicted it, and it is real): SCORE IT on the soaker.**
Flew it over site_02 post-play: the card says **"✓ WINNABLE — at $80"** and then shows a
garden table where EVERY line is DEAD (cheapest honest 19.1, best clean 32, bare 17.5) — and
NOTHING on the card says the sweep is cloth-fixed. The numbers are honest and C's charter is
sound; the SILENCE is the problem: on the one night where fabric is the whole answer, a
reader without the charter in their head concludes "winnable rig, unwinnable garden — the
night is broken." **Ask for A/B (one line, not a re-architecture): the score header should
name its fabric the way it names its funnel state — "fabric · shade cloth (sweep charter)" —
and on a storm whose stones pass the weave ruling, one footnote line: "membrane not swept —
the F key is not in this card." Who owns the line is yours to settle (B's card, A's page).**
Filed as a surface question, not a bug, exactly per C's coda.
Pushed lane/d: d8e7087 (the wiring), this entry and its sibling above. Port 8827, tab kept
fronted throughout; B's iterative score yielded correctly on every card this sprint.
[I] 2026-07-20 — **SPRINT 16 (TEETH) INTEGRATION (main).** All five lanes merged; selftest **473/0/0**
on the final tree, the exact per-lane sum at every stage (469 builders + D's 4 law pins),
verified in the browser. Deployed to partly.party/hardyards. The week is SIX nights, the
letterhead reads "standing ★ 3.0", the splash says "Six nights, six jobs, one wallet", and
your work follows you: warranty items book on the next morning's sheet (cross-client, with the
source address), reputation moves pay with its reasons printed, and the F key is finally a
night — the soaker, where every cloth line loses and the same $75 ring on membrane reads FULL.
**Integrator applications this round, all pre-authorized:** hud.js keep-both (A's repline + B's
fabricnote, siblings by B's own design); E's three palette hunks verbatim (pergola, glasshouse,
pool ring — _v1 suffixes present); no other cross-lane edits needed — the seam discipline held
so well that five lanes touching week/hud/sail/rigging/editor in one sprint produced exactly
ONE code conflict, and it was the keep-both its author predicted.
**The sprint's honest moments, for the record:** B REFUSED gate 2.3's pin with receipts (bare
beds win the earlybuster and southerly nights by 30+; a writable pin would be a fudge — shipped
`_separation_finding` blocks and a pinned-XOR-refused assert instead, balance fight filed for
A). C's ponding-is-fabric-blind finding was ruled a known simplification with the comments
corrected rather than silently fixed mid-sprint. D's poison-poke heal edge (a fully-broken sail
can't re-arm the heal) and the soaker score-header disclosure (the card says WINNABLE over an
all-DEAD cloth garden without naming its cloth charter) are filed for B and A/B respectively.
⚖️ **GATE 6 CARRIES A RULING ONLY JOHN CAN MAKE: the arithmetic evicted the WILD NIGHT.** Six
slots cannot hold five old storms + the swing lawn's survived-storm debut + the soaker under
the one-variable law, and every other candidate night is load-bearing (D's filing has the full
accounting). The wildnight — the marquee storm, the pyrrhic-win canon, A's separation pin's
home — is currently NOT IN THE WEEK. D's one-line alternative: a seven-night week. John: play
the six at partly.party/hardyards, then rule — six without the wildnight, or seven with it.
Everything else about arc 1 (teeth) is DONE.

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

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

@ -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,18 @@
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 { NIGHTS, REP } from './week.js';
// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length —
// and these surfaces used to hardcode "five". A six-night week whose splash,
// morning header and end card still said 5 would be the paper lying, so every
// count below reads the ladder. The word map keeps the splash's prose voice;
// an unmapped length falls back to the digit, which is honest if inelegant.
const NIGHT_COUNT = NIGHTS.length;
const NIGHT_WORD = { 5: 'Five', 6: 'Six', 7: 'Seven' }[NIGHT_COUNT] ?? String(NIGHT_COUNT);
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
@ -47,6 +59,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.
@ -174,6 +194,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
@ -512,7 +541,7 @@ export function createHud(d) {
</div>
</div>
<p class="premise">Rig shade sails, then keep the client's garden alive
through the storm. Five nights, five jobs, one wallet.</p>
through the storm. ${NIGHT_WORD} nights, ${NIGHT_WORD.toLowerCase()} jobs, one wallet.</p>
<div class="sect">THE CONTROLS</div>
<div class="keys">
${[
@ -758,6 +787,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">
@ -779,11 +809,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
@ -798,7 +840,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>` : '';
@ -836,6 +878,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}
@ -880,9 +929,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// scraped one, and a third for the ending nobody knew existed until the
// economy was measured: solvent, with the gardens dead behind you.
const COPY = {
clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.",
clean: ['THE WEEK HELD', `${NIGHT_WORD} nights. Everything's still where you put it.`,
"Nobody thanks you for the storm that did nothing. That's the job."],
scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.',
scraped: [`${NIGHT_WORD.toUpperCase()} NIGHTS, ${NIGHT_WORD.toUpperCase()} MORNINGS`,
`You made the least wrong call ${NIGHT_WORD.toLowerCase()} times running.`,
'Some of it held. You know which bits didn\'t.'],
solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
'You can be paid all week and still have nothing to point at.'],
@ -892,15 +942,32 @@ 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">
<h1>${head}</h1>
<h2>${sub}</h2>
<div class="row"><span>nights survived</span><b>${nights}/5</b></div>
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
<div class="row"><span>nights survived</span><b>${nights}/${NIGHT_COUNT}</b></div>
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/${NIGHT_COUNT}</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>`;
@ -957,9 +1024,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
@ -972,7 +1046,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>
@ -996,6 +1070,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">
@ -1005,15 +1080,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
card.innerHTML = `<div class="card jobcard">
${letterhead}
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
<h2>${r.subtitle}</h2>
${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. */''}
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>` : ''}

View File

@ -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
// ---------------------------------------------------------------------------
@ -883,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),
};
}
@ -955,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'); },
);
@ -975,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
@ -995,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);

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';
@ -215,17 +215,25 @@ export default async function run(t) {
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights, each a storm and a site', () => {
assertEq(NIGHTS.length, 5);
t.test('the week is six escalating nights, each a storm and a site', () => {
// SPRINT16 gate 4 (D's wiring): five → six. The swing lawn debuts night 4
// under Tuesday's southerly; the soaker closes the week over the corner
// block. The deeper pins — one-variable law, survived-storm debut, the
// measured soaker pairing — live in d.test.js next to the wiring's owner.
assertEq(NIGHTS.length, 6);
// SPRINT10: a night is {storm, site} now. nightAt() normalises either shape.
assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night');
assertEq(nightAt(3).storm, 'storm_03_southerly', 'night four repeats the southerly — the yard is the only new thing');
assertEq(nightAt(3).site, 'site_03_swing_lawn', 'night four is the swing lawn');
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is still the ice night');
assertEq(nightAt(5).storm, 'storm_06_soaker', 'night six is the fabric bet');
assertEq(nightAt(5).site, 'site_02_corner_block', "and it flies over the corner block — C's measured pairing");
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard');
assertEq(nightAt(0).site, 'backyard_01', 'the retainer nights are the backyard');
// SPRINT13 gate 1.4: the unsavable-garden flag is night 5's alone, and a
// night without it is presumed savable — new nights can't inherit the excuse.
assertEq(nightAt(4).gardenBeyondSaving, true, 'night 5 is beyond saving BY DESIGN — canon, ruled');
for (let i = 0; i < 4; i++) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
for (const i of [0, 1, 2, 3, 5]) assertEq(nightAt(i).gardenBeyondSaving, false, `night ${i + 1} is savable`);
const w = createWeek();
assertEq(w.night, 1); assertEq(w.bank, 80);
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
@ -234,9 +242,12 @@ export default async function run(t) {
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.night, 5);
assert(w.isFinalNight, 'night five is the last');
assert(!w.isFinalNight, 'the ice night is no longer the last — its dawn breaks have a morning to book on now');
w.advance();
assertEq(w.night, 5, 'the ladder does not walk off its own end');
assertEq(w.night, 6);
assert(w.isFinalNight, 'night six is the last');
w.advance();
assertEq(w.night, 6, 'the ladder does not walk off its own end');
});
t.test('money persists across nights, and the bank is next night\'s shop', () => {
@ -253,7 +264,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');
@ -289,14 +303,15 @@ export default async function run(t) {
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
assert(j.site, `night ${i + 1} has a yard`);
}
// The week's shape, which is DATA and not decoration: four nights on
// retainer at one yard while they're away, and ONE short-notice callout to
// a stranger's corner block. Night 3 landing differently is the whole point
// of the ladder — a different site MEANS a different client.
// The week's shape, which is DATA and not decoration: the Hendersons'
// retainer, one short-notice callout to a stranger's corner block, the
// Delaneys' swing lawn — and the corner block calling BACK, which is
// SPRINT16's whole thesis (your work follows you) written into the ladder.
const clients = NIGHTS.map((_, i) => nightAt(i).client);
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
assertEq(new Set(clients).size, 3, 'three clients: the retainer, the one-off, and the swing lawn');
assert(clients[2] !== clients[0], 'night 3 is somebody else');
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
assertEq(clients[5], clients[2], 'night six is the corner block client again — a return booking, not a stranger');
});
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
@ -383,10 +398,17 @@ export default async function run(t) {
assertEq(s.outcome, 'gameover', 'you cannot rig four corners, so the week is over');
// On the last night there is no next shop to be unable to afford, so being
// broke is just being broke — the week still finished.
// broke is just being broke — the week still finished. SPRINT16: the last
// night is SIX now, and night five going broke is a real game over again —
// both directions pinned so the re-ordering can't quietly move the rule.
const w2 = createWeek();
for (let i = 0; i < 4; i++) w2.advance();
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
for (let i = 0; i < NIGHTS.length - 1; i++) w2.advance();
assert(w2.isFinalNight, 'precondition: the walk reached the final night');
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'the final night always ends the week, not the run');
const w3 = createWeek();
for (let i = 0; i < 4; i++) w3.advance();
assertEq(w3.settle(ruin, def, 80).outcome, 'gameover',
'night five is not the final night any more — going broke there ends the run');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
@ -534,6 +556,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 < NIGHTS.length; 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, 'six clean nights ride the clamp to the top of the scale');
assertEq(w.log[NIGHTS.length - 1].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 +806,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

@ -20,6 +20,9 @@ import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
// SPRINT16 gate 4: the six-night law pins at the bottom of this file read the
// ladder. week.js is contracts-only underneath, so this stays headless-safe.
import { NIGHTS, nightAt } from '../week.js';
const DT = FIXED_DT;
@ -1135,4 +1138,61 @@ export default async function run(t) {
assertEq(repaired, 0, 're-rig fires with a spare');
assertEq(p.carrying, null, 'and consumes it');
});
// ================================================================ SPRINT16 gate 4
// THE SIX-NIGHT LAW, pinned next to its owner. a.test pins the ladder's
// shape (which storm, which site, which slot); these pin the RULES the
// ordering must obey, so a future re-order that keeps six nights but breaks
// the law goes red with the law's own words. Every threshold here is
// ROADMAP principle 5 or a measured THREADS receipt, not taste.
t.test('SPRINT16 six-night law: exactly one new site, exactly one new storm, never the same night', () => {
const nights = NIGHTS.map((_, i) => nightAt(i));
const PRE_S16_SITES = ['backyard_01', 'site_02_corner_block'];
const PRE_S16_STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_02b_icenight',
'storm_03_southerly', 'storm_03b_earlybuster'];
const newSiteNights = nights.filter((n) => !PRE_S16_SITES.includes(n.site));
const newStormNights = nights.filter((n) => !PRE_S16_STORMS.includes(n.storm));
assertEq(newSiteNights.length, 1, 'exactly ONE night is the new site');
assertEq(newSiteNights[0].site, 'site_03_swing_lawn');
assertEq(newStormNights.length, 1, 'exactly ONE night flies the new storm');
assertEq(newStormNights[0].storm, 'storm_06_soaker');
assert(newSiteNights[0] !== newStormNights[0],
'and they are NOT the same night — a player who loses must be able to name what beat them');
});
t.test('SPRINT16 six-night law: the new site debuts under a storm already survived', () => {
const idx = NIGHTS.findIndex((_, i) => nightAt(i).site === 'site_03_swing_lawn');
assert(idx > 0, 'the swing lawn is in the rotation and is not night one');
const debut = nightAt(idx);
const earlier = NIGHTS.slice(0, idx).map((_, i) => nightAt(i).storm);
assert(earlier.includes(debut.storm),
`night ${idx + 1} flies ${debut.storm}, which must have flown on an earlier night — `
+ 'at the swing lawn\'s debut the yard is the ONLY new variable');
});
t.test("SPRINT16 six-night law: the soaker flies the corner block — C's measured pairing", () => {
// THREADS [C] 2026-07-20: the backyard's buyable geometry caps hail cover
// over the bed at ~31% — best membrane line there reads 37.5, a bet with
// no win in it. The soaker over anything but site_02 needs a re-measure
// FIRST; this pin is where a re-pairing finds that out.
const soaker = NIGHTS.map((_, i) => nightAt(i)).find((n) => n.storm === 'storm_06_soaker');
assert(soaker, 'the soaker is in the week');
assertEq(soaker.site, 'site_02_corner_block',
'storm_06 over any other yard is unmeasured — fly gardenfly over the new pairing before moving this');
const debutIdx = NIGHTS.findIndex((_, i) => nightAt(i).site === soaker.site);
const soakerIdx = NIGHTS.findIndex((_, i) => nightAt(i).storm === 'storm_06_soaker');
assert(debutIdx < soakerIdx, 'and the yard was taught on an earlier night — the storm is the only new variable');
});
t.test('SPRINT16 six-night law: the beyond-saving night is neither new nor final', () => {
const flagged = NIGHTS.map((_, i) => nightAt(i)).filter((n) => n.gardenBeyondSaving);
assertEq(flagged.length, 1, 'exactly one designed loss in the week');
assertEq(flagged[0].storm, 'storm_02b_icenight', 'and it is the ice night — the flag is a measured fact about');
assertEq(flagged[0].site, 'backyard_01', 'THIS yard under THIS storm (bare 0.0, best buyable 27.7, win at 50)');
const idx = NIGHTS.findIndex((_, i) => nightAt(i).gardenBeyondSaving);
assert(idx !== NIGHTS.length - 1,
'the designed loss must not be the final night: its dawn breaks need a morning to book warranty on '
+ '(A filed the night-5-final gap in THREADS — the six-night order is what closes it)');
});
}

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

@ -1,7 +1,9 @@
/**
* SHADES the week. Lane A owns this file.
* (SPRINT16 gate 4: the NIGHTS ladder and the two new briefs are Lane D's
* edits, by the sprint's explicit grant A owns everything else here.)
*
* Five nights, one yard, one wallet. This is deliberately a thin wrapper around
* Six nights, one wallet. This is deliberately a thin wrapper around
* the phase machine rather than a new system: main.js already knows how to run
* forecast prep storm aftermath, and the week's whole job is to answer
* three questions between rounds which storm is tonight, how much money do you
@ -20,30 +22,51 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 1 Sea Breeze gust 10.5, no hail the tutorial: nothing can hurt you
* 2 Southerly Buster gust 20.0, hail the change, and hail, arrive
* 3 Early Buster gust 20.0, hail same teeth, change comes EARLY
* 4 Wild Night gust 30.0, hail the one the whole repo tuned for
* 5 Ice Night gust 28.5, hail less wind, more ice
* 4 Southerly again gust 20.0, hail the storm you know, the yard you don't
* 5 Ice Night gust 28.5, hail the most wind of the week, and worse ice
* 6 Pea-Hail Soaker gust 15.2, fine hail the night the default fabric is wrong
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
*
* SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard;
* night 3 moves to the corner block the same "looks like night 2 and isn't"
* beat, sharpened: it's a new yard you've never rigged, its carport is a trap,
* and its storm carries the southerly change that makes the venturi scream. The
* plain-string form is still accepted (defaults to backyard_01) so nothing that
* reads NIGHTS[i] as a storm key breaks.
* SPRINT10: a night is now a STORM and a SITE. Night 3 moves to the corner block
* the same "looks like night 2 and isn't" beat, sharpened: it's a new yard
* you've never rigged, its carport is a trap, and its storm carries the
* southerly change that makes the venturi scream. The plain-string form is
* still accepted (defaults to backyard_01) so nothing that reads NIGHTS[i] as
* a storm key breaks.
*
* SPRINT11: a night is now a JOB. Same ladder, same yards what's new is the
* frame DESIGN.md has had from line one: *"work arrives as callouts each one a
* site with existing conditions, a client brief, a budget, and a forecast
* window."* You are not surviving five nights; you are working five jobs.
* SPRINT11: a night is now a JOB. What's new is the frame DESIGN.md has had
* from line one: *"work arrives as callouts each one a site with existing
* conditions, a client brief, a budget, and a forecast window."* You are not
* surviving the week; you are working its jobs.
*
* The week that fell out of the data, once it was asked to name a client: the
* Hendersons are away (SPRINT11's own line they leave for Cairns), so four of
* the five nights are the same yard, on retainer, through a bad week. Night 3 is
* a different site, so it is necessarily a different client a short-notice
* callout to a yard you have never rigged, on the night the change comes early.
* That is not flavour bolted on: it is what `site: site_02_corner_block` already
* MEANT, finally said out loud. The corner block lands harder because it's a
* stranger's place and you're there at short notice.
* Hendersons are away (SPRINT11's own line they leave for Cairns), so the
* backyard nights are the same yard, on retainer, through a bad week. A
* different site is necessarily a different client.
*
* SPRINT16 gate 4 (Lane D, by the sprint's explicit grant): SIX nights. The
* swing lawn enters the rotation at night 4 under the southerly the ONE
* variable rule (ROADMAP principle 5), applied literally: at its debut the only
* new thing is the yard, because the storm is Tuesday's, already survived. The
* soaker flies night 6 over the corner block a yard night 3 taught because
* C MEASURED the pairing (THREADS [C] 2026-07-20): the backyard's buyable
* geometry caps hail cover over the bed at ~31%, so over there the membrane
* bet has no win in it. Exactly one night is the new site (4), exactly one
* flies the new storm (6), and they are not the same night.
*
* THE STRUCTURAL CONSEQUENCE, SAID OUT LOUD (D, filed in THREADS for John's
* gate 6): six slots cannot hold seven nights. Five shipped storms + the
* soaker is six distinct storms; the swing lawn's debut must REPEAT a storm
* the player already survived; so exactly one shipped storm leaves the week
* and every other candidate is load-bearing (gentle = the tutorial and the
* calm-day preload; southerly = the hail intro and the early buster's
* referent; early buster = site_02's debut, which the soaker's pairing needs;
* icenight = the beyond-saving flag, canon). THE WILD NIGHT (storm_02) is out
* of the rotation. Its storm file, its tests, and the pinned backyard
* separation all stand it is un-flown by the week, not deleted. If that
* eviction is wrong, the honest fix is a SEVEN-night week (arc 2's own range
* is "five-to-seven"), which is one entry re-added here a ruling, not a
* refactor.
*
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
*/
@ -72,17 +95,29 @@ export const NIGHTS = [
+ 'to tie off to.',
},
{
storm: 'storm_02_wildnight', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like '
+ 'they\'re watching, because Friday they will be.',
// SPRINT16 gate 4 — THE NEW SITE, and nothing else new: Tuesday's southerly
// over D's swing lawn. The brief does what "plenty to tie off to" did for
// the carport — the client's own voice sells the trap (the crossbar between
// the swing apexes is a dead-level rail and NOT an anchor; the apexes are
// 0.45 steel that bills $140) — and the kid gives the bed its reason.
storm: 'storm_03_southerly', site: 'site_03_swing_lawn',
client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn',
brief: 'Same southerly as Tuesday, over a lawn you\'ve never rigged. The swing set is Ruby\'s — '
+ 'her dad reckons the crossbar\'s bolted right through, tie to that and save yourself a '
+ 'post. The veggie bed is her school project, so it comes home alive.',
},
{
storm: 'storm_02b_icenight', site: 'backyard_01',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
+ 'Last night before they land. Nobody\'s pretending the seedlings ride this one out — keep '
+ 'the sail on its feet and the ice off the windows.',
// SPRINT16 gate 4 wording note (D): this brief used to open "Less wind than
// last night" — true when the wild night (gust 30) sat at night 4. The wild
// night left the six-night rotation (see the ladder comment), so the ice
// night is now the most wind the week throws; the sentence moved with the
// facts. The teaching half — level with the player that the bed is already
// lost — is untouched.
brief: 'The most wind of the week and golf-ball ice with it. Last night before they land. '
+ 'Nobody\'s pretending the seedlings ride this one out — keep the sail on its feet and '
+ 'the ice off the windows.',
// SPRINT13 gate 1.4 — A's ruling, DESIGN CANON: night 5's garden is BEYOND
// SAVING, by design, at any price. Measured game-true (B's pay table and
// A's probe, agreeing): bare bed 0.0 DEAD; best buyable garden 27.7; best
@ -99,6 +134,20 @@ export const NIGHTS = [
// flag in the same commit or the verdict lies in the other direction.
gardenBeyondSaving: true,
},
{
// SPRINT16 gate 4 — THE NEW STORM, over a yard night 3 taught. C's pairing
// receipt (THREADS [C] 2026-07-20): the soaker MUST fly the corner block —
// the backyard caps hail cover at ~31% and the bet has no win there. The
// brief sells the WRONG default the way the carport got sold: fine hail
// through a 2 mm weave is exactly the night cloth loses, and the client is
// the one telling you it's easy. The forecast (C's stones/rain lines) is
// where the truth argues back — the brief stays in character.
storm: 'storm_06_soaker', site: 'site_02_corner_block',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'The Vasilaros place again — they asked for you by name this time. Hardly any wind in it, '
+ 'just rain and a fine rattle of hail; the kind of night you could rig in your sleep. '
+ 'Whatever you flew last time will do, they said.',
},
];
/**
@ -171,6 +220,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 +253,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 +359,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 +383,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 +433,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 +466,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 +495,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 +520,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 +548,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 +591,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.