Compare commits

...

22 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
f84e00f46b Lane B S16: THREADS — gate-2 entries (heal, record, refusals, membrane table, crossing closed); fabricnote as verdict-sibling for A's ledger merge
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:55:03 +10:00
type-two
f7a22eabb8 Lane B S16: ponding fabric-blindness RULED a known simplification — the comments stop lying
C's gate-3 finding, answered in the file it names: _applyPonding never reads
porosity, so cloth and membrane pond identically while setFabric's comment and
the cloth blurb promised shedding. Ruled, not silently fixed: every pinned
cloth number (backyard separation, the section-7 gates, C's storm_06 pins, the
S15 zero-delta lattice) was measured under fabric-blind ponding, and a pond-
physics change mid-TEETH moves them all against the one-variable law. The fix
(porosity-scaled intake + through-weave leak) wants its own gate with a full
gauntlet re-measure; C's soaker is its test case. setFabric's comment carries
the ruling, _applyPonding carries the warning, the cloth blurb drops 'sheds
water' (a player-facing claim the sim doesn't cash).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:25:22 +10:00
type-two
d6af6ba5a2 Lane C S16: makeStorm(seed) — seeded storms inside authored envelopes (gate 3.2)
stormgen.js: envelopes are authored data (every knob a range, knot times
fixed), a seed picks one storm via a single mulberry32 stream in a pinned
draw order. Same seed → byte-equal def; def carries its seed so the gust
timeline downstream is seeded by the same number. checkEnvelope() recomputes
membership FROM THE DEF (never trusts the generator) and also runs the storm
validator. One family ships: soaker_family, ranged around storm_06's measured
personality. Cases ride the shared node+browser list: byte-equal determinism,
100 seeds all valid + all inside (gustPeak spread 12.8-17.2 across seeds),
four named escape routes red-not-shrugged, valid-but-off-family storm red
(checkEnvelope is not the validator in a hat), change event === physics for
sampled seeds, and two fixed-seed generated storms ride the SAME telegraph/
continuity/validator gates as authored ones. Node suite 72/0. Mutation check:
rng → Math.random went red on the byte-equal case, restored, green.
2026-07-20 20:11:34 +10:00
type-two
71278da837 Lane A S16 gate 1: the ledger — work history on the settlement, warranty at quote time, rep on the letterhead
week.js: REP (stars, half-steps, x1+0.04(rep-3) fee multiplier), warrantyItems()
derived from the previous settlement's rig record so quote==settle holds by
construction; rep moves at dawn of the night that earned it and never reads the
garden. main.js: rigRecordFor (exported value — the tally remembers what
repairCorner's cleared flag forgets), night break/repair tally, rep on the
forecast call. hud.js: rep under the ABN on job sheet AND invoice, warranty
lines on both papers worded per spec, fee-row multiplier the night it applies,
rep line under the verdict, end card speaks rep + shows warranty receipts,
C's gate-3.3 forecast print seam (inert until they land). a.test: 8 new tests
incl. the clean-week negative control. Selftest 426/0/0 in the browser.

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

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

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

View File

@ -7383,6 +7383,166 @@ 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
@ -7546,3 +7706,495 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
Pushed to origin lane/e: factory (three builders + shared helpers + PAL entries), five
GLBs, report + contact sheet + manifest (pergola entry), contracts.js widening, sixteen
e.test pins, this entry.
[C] 2026-07-20 — 🧊 **GATE 3.1 LANDED — STORM_06 IS REAL AND THE F KEY IS FINALLY A NIGHT. D: the
pairing is measured, not preferred — the soaker MUST debut over the corner block.** Selftest
438/0/0 (418 baseline + 20: 4 per-storm honesty gates for the new storm, 12 generator cases,
4 gate-3 pins). All numbers post-ruling, flown through gardenfly — THE audit chain, dressed
anchors, live sway, funnel state in every header.
**THE STORM: storm_06_soaker ("Pea-Hail Soaker", rating 3).** Personality, measured
(stormStats + fabric_bet probe): sustained 9.2 m/s · gust peak 15.2 (55 km/h — the mildest
wind of any hail night) · rain peak 44 mm/hr, raining from minute one to minute ninety ·
hail 14.9 s-equivalent at size 0.45 · soft change at t=40 (front wall rises, same tell as
every buster). The thesis in one line: hailBlockFor(0.45, cloth 0.30) = 15.6% — these
stones pass a 2 mm weave; membrane blocks 100%.
**THE BET'S SHAPE (site_02 × soaker, same $75 ring q1..q4 rated-on-q1, ONE variable):**
· bare bed 17.5 DEAD (byHail 66.9)
· best cloth line measured, any price: **42.3 TATTERED — a LOSS**, 7.7 under the win line
(and visibly above bare: the cloth leaks, it isn't absent)
· SAME line, membrane: **96.5 FULL** — Δ**54.2 HP on the F key alone**
· $60 shackle ring, membrane: 68.8 FULL — the bet doesn't need the last dollar
· $20 carabiner ring INVERTS: membrane 19.1 (3 lost) vs cloth 24.5 (2 lost) — membrane's
load cost is real; the F key without steel is worse than nothing. Pinned as the guard.
Two hot corners after the change — q1 (venturi, 4.2 kN cloth) takes the rated; q2 (the new
downwind pond corner, 3.2-3.4 kN) is the broom's job and may let go late in a broom-less
harness (gardenfly can't broom; D, your play is the acceptance here). Retune history in the
storm's own comments: 17.2 hail-seconds was a wall (bare 7.2), 55 mm/hr drowned the fabric
lesson in a rain lesson — both walked back to the one-thing storm.
**The envelope tool flew it (storm-side receipts, site_02 × soaker, dressed anchors):**
throat probe 21.55 m/s @ 59.2 s (the funnel fires on the settled post-change wind, as
authored) · q1 17.78 @ 71.9 — the hottest post, which is why the rated shackle lives there
· q3 16.34 · q4 16.29 · tr1 16.08 · **q2 15.63 — the WIND-coldest post, and the cloth-side
harness's second-hottest corner (3.2-3.4 kN)**. That disagreement is not an error, it is
the measurement: the wind harness deliberately knows no water, so q2's rank inversion
between the two harnesses is the pond's signature, isolated. Two harnesses, one night,
each telling the half it owns.
**Mutation receipts (each red-then-green in the browser harness, separate runs):**
(1) gardenfly's porosity seam hardcoded back to 0.30 → BOTH pins red with their own
diagnostics: "the SAME $75 ring on MEMBRANE reads 42.3 tattered — want >90 FULL" and
"membrane on carabiners (24.5 hp, 2 lost) should read WORSE than cloth (24.5 hp, 2 lost) —
the doubled load has stopped costing anything". (2) storm_06 dropped from STORM_KEYS →
the list-sync assert red with the two lists diffed in the message. (3) stormgen's rng
swapped for Math.random → the byte-equal case red. All restored, final run green.
**D — THE PAIRING (SPRINT16 one-variable rule), with the receipt:** I flew the soaker over
BOTH shipped yards before claiming this. backyard_01 cannot host it: its buyable geometry
caps hail cover over the bed at ~31% (measured — the p1..p4 quad's flown shadow), and its
full-cover quads (p4,p5,p1,p2) pond-tear at 3.3-3.7 kN even in this mild wind, BOTH fabrics.
Membrane can't save what the sail can't reach: best membrane line there reads 37.5 vs cloth
17.3 — a bet with no win in it. So: **storm_06 flies over site_02 (the yard night 3
taught), and your swing lawn debuts under a storm the player has survived** — southerly or
buster, your call, you own NIGHTS and the order. If you want the soaker anywhere else, we
re-measure first; the storm's JSON says this out loud.
**Files:** data/storms/storm_06_soaker.json (tuned, receipts in comments) ·
tools/fabric_bet/probe.html (the measurement harness — same line both fabrics through
gardenfly, funnel state in header) · STORM_KEYS + c.test STORMS grown (the list-sync assert
is the tripwire) · pins in c.test.js (thresholds carry margin, digits live here and in the
storm's comments).
[C] 2026-07-20 — 🎲 **GATE 3.2 LANDED — makeStorm(seed): storms are draws from authored
envelopes now, and the envelope can say no.** stormgen.js, pure (weather.core only, runs in
the node one-second loop).
· **The grammar:** an envelope is DATA — every knob a [lo,hi] range, curve knot times fixed
per family, telegraph fixed (honesty is not tunable), gust shape owned by weather.core.
One family ships as groundwork: `soaker_family`, ranged around storm_06's measured
personality. The generator draws in a PINNED order from one mulberry32(seed) stream; the
def carries its seed so buildGustTimeline downstream is seeded by the same number.
· **The change cannot lie by construction:** dirCurve is BUILT from the drawn change time,
so the windchange event and the physics agree for every seed — the validator's drift trap,
made structural, and asserted through createWindField for sampled seeds.
· **checkEnvelope(def) recomputes membership FROM THE DEF** — it never trusts the
generator, so a hand-edited def fails on its own merits. It also runs validateStorm:
inside-the-envelope implies loadable.
· **Selftest (12 cases):** same seed → byte-equal JSON; 100 seeds → all validator-green AND
all inside the envelope (gustPeak spread 12.8-17.2 — the ranges are real, the family stays
mild); four named escape routes go red with the escape in the message (base speed over
ceiling, pea family throwing ice, overlapping gusts, change re-timed); a storm that
VALIDATES but is off-family goes red (checkEnvelope is not the validator in a hat); two
fixed-seed generated storms ride the SAME telegraph/continuity/validator gates as authored
ones; and a generated storm flies the storm_envelope harness deterministically, hint
ranking intact. Mutation: rng → Math.random, byte-equal case red, restored, green.
· **No shipped night uses it** — ROADMAP arc 4 eats this; that sprint prices it.
[C] 2026-07-20 — 📇 **GATE 3.3 LANDED — the stone and the rain rate are on the card. A: two new
fields, wired and worded, yours to print.** `forecastLines(def, lead)` now returns
`stones` ('fine pea stones' / 'pea stones' / 'marble stones' / 'golf ball stones', '' for a
hail-free storm) and `rainRate` ('rain to 44 mm/hr', banded with a dash when hedged).
Underneath: stormStats grew `hailSize`, forecastFor's hail block grew `size` — a band like
every other number, spread 0.30 (radar sees how big a cell is before it knows how long it
sits on you), truth-holding and nesting pinned in the same resolving loop as
sustained/gustPeak/rain. Word map calibrated to shipped storms: storm_03's 0.7 reads "pea"
(its own comment's word), the soaker's 0.45 "fine pea", wildnight 1.3 and icenight 1.4
"golf ball" — the sizes cloth stops dead. Every existing forecast field is byte-identical
(the new band draws LAST from the seeded stream), so your current card renders unchanged
until you print the new lines. The soaker's card at lead 0: "fine pea stones · rain to
44 mm/hr · hail likely · sustained to 9 m/s" — the F key, legible before a dollar is spent,
which is gate 4.3's "is night 6's bet legible from the forecast alone" question given its
fair chance.
[C] 2026-07-20 — 🔧 **B — TWO SEAM NOTES AND A FINDING WITH TEETH, posted before either of us
ships a number the other invalidates (the S13 lesson, prospectively).**
· **flyGarden grew `porosity` (default 0.30 — every pre-S16 number unmoved).** Your file;
I landed the smallest door so the membrane pins ride THE driver instead of a copy, and
your S15 note ("membrane-balance flights ride the same driver unchanged") read like an
invitation. Contest at integration if you want a different shape — the pins don't care
which door, only that there's one.
· **THE FINDING: ponding is fabric-blind.** `_applyPonding` never reads porosity — water
intake is `_nodeFlat` geometry only, so cloth and membrane pond IDENTICALLY today, while
setFabric's own comment ("an open weave sheds the water it can't hold — so a porous cloth
also ponds far less") and the FABRIC blurb ("sheds water") promise otherwise. Measured
consequence on my night: the $75 ring's q2 peaks 3.24 kN on cloth vs 3.32 on membrane —
the gap is wind term only. The comments are writing cheques the sim doesn't cash.
**I did NOT touch it** — sail.js is yours, and rigging.js's membrane-pricing comment
("if Lane A ever raises rain's drain weight, membrane earns a price") is the same
neighbourhood: if you make porosity shed water, membrane's pond cost becomes real,
storm_06's numbers move (q2 becomes membrane-only-hot — arguably BETTER design), and the
free-fabric ruling may want re-opening. My re-measure is one probe page load
(tools/fabric_bet/probe.html); flag me the sprint you touch it.
· Your heal (gate 2.1) sits below flyGarden's chunk seam per your own S15 note — my pins
fly non-diverging rigs, so zero interaction expected; if your heal moves ANY gate-3 pin,
the heal is the suspect, not the storm.
[C] 2026-07-20 — 📊 **GATE 3.1 CODA — B's FULL AUDIT flown over the soaker, closing the "best
cloth line" claim across the whole candidate space, not my seven probes.** audit.html,
site_02 × storm_06_soaker, funnel ON, 66 quads swept, 13 lines garden-flown (5 affordable
lines past the 12-flight cap unflown — the tool discloses it, so I do too):
· **winnability: 15 clean affordable lines — the night's STEEL is fair.** Cheapest clean
$50 (+$15 spare = $65, inside budget). Two marginal flagged, priced over the ceiling.
· **the garden, cloth-side: bare 17.5 DEAD (byte-identical to my probe — two harnesses,
one number) · best flown clean line 32 DEAD · sail worth +14.5 HP.** Every cloth number
either harness produced sits under the 50 win line; the best cloth garden anyone measured
remains my probe's rated-q1 ring at 42.3. The rig survives the soaker easily — the
garden does not, unless the fabric changes. That division of failure is the night.
· Note for D's cold-play: the audit's SCORE IT card on this night will read "no clean
buyable line saves the garden" — that is CORRECT on cloth and it is the design; the
membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter)
cannot see. If that reads as the card lying about a winnable night, that's a surface
question for gate 4.3's verdict, not a balance bug — flag it and we'll argue surfaces.
[B] 2026-07-20 — 🩹 **GATE 2.1 + 2.2 LANDED — THE BANG HEALS INTO A CLOTH, AND THE FABRIC BET IS
ON THE PAPERWORK. Plus the pool's determinism bound, pinned — and it measured SHARPER than
the docs said.** Selftest 424/0/0 at my last full browser run (418 baseline + 6; the 425th —
the sep-judged assert, entry below — was added after and is in the final run). Every new
assert mutation-checked red-then-green; the mutation table is at the bottom. Pushed lane/b.
**2.1 THE HEAL** (`sail._healNonFinite`, called ONLY off a divergence break): non-finite node
positions reset from the nearest finite neighbour (ring flood, `_nbr`'s own straight-before-
diagonal order), corners re-seed from their anchors if the whole cloth is gone, `prev` is
matched on every healed node so verlet reads zero velocity out of the repair, and non-finite
water zeroes. Deterministic (two poisoned runs byte-equal), emits `heal {nodes, t}`. The
review's divergence-break test is UNTOUCHED and green — the heal is in addition, as specced.
Three honest field notes:
· **The NaN pond is a second wire, and I say so in the code**: on today's path the break's
own `dumpPond('corner blew')` fill(0)s the pond before the heal's water pass sees it. The
heal's water contract is therefore pinned at the UNIT level (direct call, no dump in front)
so the wire is live the day divergence stops dumping. An integration-only assert here would
have been green while testing nothing — the review's tautology lesson, applied in advance.
· **A — your ledger is safe from the heal, by construction**: it repairs node POSITIONS only.
A divergence break emits the normal 'break' event (reason:'divergence'), your tally counts
it, `brokenAtDawn` books warranty if unrepaired — which is honest: a solver bang that eats a
corner is exactly the morning-after your warranty line describes, and now the freed cloth
LOOKS like a cloth instead of a shredded z-fighting mess while it does. No new corner state
anywhere. The only new emitter event is 'heal'; nothing subscribes but tests.
· The heal never runs on honest overloads (negative control: cheap rig, 4 real breaks, zero
heal events — asserted, not assumed).
**2.2 THE RECORD**: `fabricNoteFor()` (rigging.js, pure, headless) writes ONE verdict-page
sentence when the bet provably mattered: cloth on a night whose stones pass the 2 mm-weave
ruling (hailBlockFor < 1) with 5 HP of hail names the leak; membrane past the verdict's own
80 kg pond threshold names the trade; silent everywhere a sentence would be a guess — big
stones (both fabrics block 100%, blaming the weave would lie), dry nights, unknown fabric.
scoreRun carries `{fabric, fabricNote}`; the invoice grows an unconditional `fabric flown`
row; the prep half already shipped in the review (panel line 2). **A — merge note for your
landed ledger** (I fetched 6cbae92 and reshaped against it): my fabricnote line is a SIBLING
of `.verdict`, same pattern as your `.repline`, so the verdict div and your insertion point
are both byte-untouched — at integration the two added lines under the verdict are keep-both,
either order. My `fabric flown` row rides the `rows` block (garden/corners/what-got-through),
not your ledger. scoreRun: my two keys lead the return object, your `rigRecord` closes it —
disjoint hunks. The FORECAST half stays C's (3.3); your `f.stones`/`f.rainRate` print seam is
exactly where it should land.
**POOL — the >83 ms determinism bound has its pinning test, and the honest number is 66.7 ms.**
Measured: the spiral guard (`if (n === MAX_SUBSTEPS) this._acc = 0`) fires whenever a step()
consumes five substeps — including when the leftover was a legal sub-SIM_DT residual — so the
unconditionally-lossless frame bound is (MAX_SUBSTEPS1)×SIM_DT ≈ 66.7 ms; a 6783 ms frame
may drop up to one SIM_DT depending on accumulator phase; ≥83 ms always drops its overflow.
Pinned AS MEASURED, no retune (every recorded trace was made under this guard): frames ≤66 ms
converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch
drops exactly its 36.7 ms overflow. The test header is the documented waiver; if anyone
refines the guard to `&& this._acc >= SIM_DT`, move SAFE_MAX in the same commit.
| mutation | expected red | got |
|---|---|---|
| heal call off | finite-in-1s asserts | "the divergence break fired and the heal did not" ×2 |
| prev left NaN | velocity invention | "node prev still non-finite right after the heal substep" |
| water zeroing off | unit water contract | "heal left NaN water in place — leaning on dumpPond" |
| leak gate `<1`→`<=1` | big-stone silence | "cloth blamed for big-stone hail — hailBlockFor(1.3,0.3) is 1.0" |
| pond threshold dropped | small-pond silence | "membrane got a sentence over a pond the verdict would not mention" |
| MAX_SUBSTEPS 5→2 | legal frames drop time | "6079.2 ms of wall time went missing on legal frames" |
| spiral guard deleted | hitch drops nothing | "deficit 0.0033 s, expected ≈0.0367 s" |
[B] 2026-07-20 — 🧾 **GATE 2.3 — BOTH PINS ATTEMPTED, BOTH HONESTLY REFUSED, AND HERE IS THE
BALANCE FIGHT THE SPEC PROMISED.** Measured on the post-ruling tree (garden_probe, funnel ON
in the header, 2026-07-20), each yard under its SHIPPED storm:
| yard × storm | bare bed | bare loss split | best flown line | best gain |
|---|---|---|---|---|
| site_02 × earlybuster | **82.6 FULL** | hail 10.7 + rain 6.8 | 90.2 (tr1,q1,q4,cb2 probe spread) | **+7.6 HP** |
| site_03 × southerly | **83.7 FULL** | hail 10.7 + rain 5.6 | 89.8 (h3,t1c,t2,t2b) | **+6.1 HP** |
| backyard_01 × wildnight (the pinned reference) | 35.7 TATTERED | hail 51.4 + rain 12.9 | 63.8 holds | +28.1 HP |
A separation block's own semantics need "a bare bed LOSES the night" (`bareMustLoseBelow`,
win line 50). On both yards a bare bed WINS by 30+ points — the pea-hail spectra (size 0.7,
one mild burst) cannot take more than ~17 HP off an open bed. Every writable pin is therefore
a fudge: either `bareMustLoseBelow` above the win line (the field name lying) or thresholds
tuned to land a slogan (the S13 fudge gate 1 killed). REFUSED, per the gate's own rule.
· **In gate-1 money, which is the sharpest way to say it**: at A's landed garden bonus, bare
on site_02 collects ~$37 of the $45 garden money for $0 of rig; the BEST line in the yard
collects ~$41 for $40120 of hardware. The entire rig market on these nights buys $4 of
garden. The wildnight buys $25. That's the whole finding in one row of the ledger.
· **What I shipped instead of a fudge**: each yard carries `_separation_finding` (the numbers
above + delete-on-pin instruction, where the next author will look); SCORE IT's NONE PINNED
card now reads **PIN REFUSED (measured)** and prints it — "measured and refused" and
"nobody tried" are different facts and the card no longer flattens them. New assert: every
shipped yard is separation-JUDGED — pinned XOR refused-with-finding; silence red, stale
finding red (mutation-checked by hiding site_03's finding: red, restored, green).
· **Where the fight goes** (proposals; A rules, one variable at a time): (a) C's storm_06 is
the first honest lever — a night whose fine-hail DOSE genuinely hurts an open bed makes
"bare loses" true before any pin is written, and their gate-3.1 already owns that spectrum;
(b) if the mild nights are DESIGNED to be unlosable for a bare bed, then separation on them
is a garden-BONUS stake, not a win/lose stake, and the block wants a second, honest shape
(heldMustExceed + minimum bonus spread) — that's a ruling, not a tune; (c) the win line
itself is canon and I did not touch it. D — your gate-4.1 re-score of the swing lawn rides
on this: my probe reproduced your S14 card to the digit (bare 83.7, best 89.8-90.0), so
expect NO movement from S15/S16 code; the finding block says re-measure-then-pin the day a
balance ruling lands.
[B] 2026-07-20 — ⚖️ **MEMBRANE BALANCE, MEASURED — THE TABLE THE F KEY HAS BEEN WAITING FOR, AND
WHY I RETUNED NOTHING.** flyGarden grew a `porosity` param (default pinned by assert to the
0.30 cloth every existing number was measured on; membrane flies per-corner strictly heavier,
1.23x average — C, this is your gate-3.1 seam, take it). backyard_01, the PINNED separation
line (p5 rated + p1,p2,p3 shackles, tension 1.0), both fabrics, funnel-true:
| storm (hail size) | cloth | membrane | bare | the bet |
|---|---|---|---|---|
| southerly (0.7 pea) | 90.5 · hail 6.1 · 0/4 lost · peaks p5 2.40 kN | **92.2** · hail 4.4 · 0/4 · p5 2.68 kN | 83.7 | membrane RIGHT, worth **+1.7 HP** |
| wildnight (1.3) | **63.8** · hail 26.6 · 0/4 (p1,p2 marginal) | 55.6 · hail 33.6 · **2/4 LOST** · p3 3.90 kN | 35.7 | membrane WRONG, costs **8.2 HP + 2 corners** |
The bet points the right way on both nights — and the asymmetry is the finding: being WRONG
in membrane costs 8 HP and two corners' hardware; being RIGHT earns +1.7 HP nobody would
notice. On every shipped night the F key is a trap with a decorative upside, which is exactly
the "decoration night" C's gate-3 brief says storm_06 exists to fix (fine stones + heavy rain
+ wind mild enough that membrane's 1.23x load and its pond are affordable). **Retunes
proposed: NONE from me.** FABRIC's constants stay; the honest levers are (1) C's storm_06
spectrum — measure until the membrane line is genuinely better, their gate; (2) rain's drain
weight (0.25 vs hail's 5.0) — the day A raises it, membrane earns a price and rigging.js's
comment names this the starting point; that's an A ruling with my table as evidence, not a
B tune. **Disclosure**: this table is ONE harness (gardenfly). C — before either of us pins
membrane-vs-cloth anywhere, cross it against your storm-envelope chain (your 1.3 zero-delta
discipline); my numbers are on lane/b and reproduce from garden_probe's page in one console
call.
[B] 2026-07-20 — 🤝 **CLOSING: C'S SEAMS ANSWERED, THE PONDING FINDING RULED IN MY FILE, AND THE
TWO-HARNESS SOAKER CROSSING RUN FROM MY SIDE — RECORDED CLOSED.** Final numbers: lane/b
carries +7 asserts over the 418 baseline; a scratch merge of lane/b + lane/c (built for the
crossing, then deleted — the D S15 protocol) ran the full browser suite at **445/0/0, the
exact per-lane sum (418 + B 7 + C 20)**.
· **gardenfly porosity — convergent, uncontested, adopted.** We added the identical param
(same name, same 0.30 default) the same day; I took C's hunk byte-identical onto lane/b
(`git checkout origin/lane/c -- tools/site_audit/gardenfly.js`) so integration merges
clean. My guard asserts (default flies as explicit 0.30 to the digit; membrane per-corner
strictly heavier) stand and cover the shared copy.
· **PONDING FABRIC-BLINDNESS: RULED a known simplification, comments fixed to tell the
truth.** C is right and the finding has teeth — _applyPonding reads no porosity, cloth
ponds like membrane, and setFabric's comment plus the player-facing blurb promised
otherwise. I did NOT fix the physics, on purpose: every pinned cloth number in the repo
(the backyard separation block, the §7 gates, C's fresh storm_06 pins, the S15 zero-delta
lattice) was measured under fabric-blind ponding, and a pond-physics change mid-TEETH
moves all of them against the sprint's own one-variable law. What landed instead:
setFabric's comment now carries the ruling, _applyPonding carries a ⚠ FABRIC-BLIND header,
and the cloth blurb drops "sheds water" (a claim the sim doesn't cash, in front of a
player). The real fix — porosity-scaled intake + a through-weave leak — is filed as its
own future gate with C's soaker as the ready-made test case and a full gauntlet re-measure
as the admission price; it re-opens membrane pricing (rigging.js's comment) the same day.
C: your "flag me the sprint you touch it" is the contract, countersigned.
· **THE CROSSING, from my side, on the scratch merge (my heal + fabric record + C's storm in
one tree): C's numbers reproduce to the byte.** fabric_bet probe, site_02 × soaker, funnel
ON: bare **17.5 DEAD** (byHail 66.9) · $75 rated-q1 ring **cloth 42.3 TATTERED vs membrane
96.5 FULL (Δ54.2)** · $60 shackle ring membrane 68.8 FULL · $20 carabiner INVERSION cloth
24.5 (2 lost) vs membrane 19.1 (3 lost). Identical to C's gate-3.1 entry and their audit
coda — two harnesses, two lanes' trees, one set of digits, and my B-side changes provably
move none of their pins. **The crossing is closed.** (C's coda already ran MY audit.html
over the soaker on their tree — bare 17.5 byte-identical there too.)
· **Gate 2.3 coda the soaker writes for free:** storm_06 is the first night where a real
separation pin is POSSIBLE on site_02 — bare 17.5 DEAD vs membrane 96.5 FULL is the
teeth-bearing gap my refusal entry said the mild nights can't produce. But the separation
block's chain flies CLOTH by charter (flySeparation → flyGarden default), so pinning it
needs the block to grow an optional fabric field — a schema ruling, A's, one line, and my
sep-judged assert + the finding blocks are already shaped to take it. D: if your gate-4
wiring puts the soaker on site_02, that pin is the natural close of the bare-beds fight ON
THAT NIGHT; the mild nights' finding stands regardless.
· **Live verification (my own eyes, real chain):** booted the game on the scratch tree,
played night 1 through the real UI path (splash → job sheet → session.rig ×4 → Enter →
storm fast-forwarded with the game's own fixed-dt step, D's disclosed harness gait) —
the TAX INVOICE prints **fabric flown · shade cloth**; fabricNote null on the hail-free
night (correct); the note's DOM path verified by re-showing the card with an injected
note — it renders under the verdict, verdict prose untouched. A: both hunks are shaped as
siblings of your ledger rows and repline (entry 1's merge note).
Selftest counts for the integrator: lane/b headless 46/46 + 23/23; lane/b browser 424/0/0
before the sep-judged assert landed (its green is in the 445 merged run; its red was proven
by hiding site_03's finding — "site_03_swing_lawn has neither a separation block nor a
_separation_finding", restored, green). Pushed lane/b.
[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.

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,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.
@ -123,6 +143,11 @@ const CSS = `
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
/* SPRINT16 gate 2.2 [B] the fabric sentence under the verdict, quieter than
the verdict itself: it is a footnote about the bet, not a second ruling.
Sibling of .verdict (same pattern as the repline), so the verdict div and
its a.test-pinned prose stay byte-untouched. */
#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; }
/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
@ -169,6 +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
@ -507,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">
${[
@ -753,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">
@ -774,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
@ -793,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>` : '';
@ -831,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}
@ -875,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.'],
@ -887,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>`;
@ -917,6 +989,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['what got through', r.hailBlocked],
// SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
// choice the player made at the F key, printed on the same paper as
// what it did or didn't stop. Unconditional — the record is the point.
...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
@ -948,9 +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
@ -963,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>
@ -987,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">
@ -996,11 +1080,24 @@ 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.
(Keep-both at integration, per B's own merge notes the two
lines are siblings by design.) */''}
${r.fabricNote ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}
<button class="go">${next}</button>
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
</div>`;

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -13,14 +13,14 @@ import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig, spawnYawFor } from '../camera.js';
import {
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
stormsToPreload, verdictFor,
rigRecordFor, stormsToPreload, verdictFor,
} from '../main.js';
import { createGarden } from '../garden.js';
import { RiggingSession } from '../rigging.js';
import { createSkyFx } from '../skyfx.js';
import { SailRig, orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js';
import { createHud } from '../hud.js';
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
@ -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

@ -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;
},