Compare commits

..

20 Commits

Author SHA1 Message Date
type-two
a012225259 Sprint 17 integration [I] entry: the board live, seven nights, convergence recorded 2026-07-21 06:32:13 +10:00
type-two
9498dfc1b9 S17 integration: apply C's offerBand hunk — the board stops lying about night 7 (hail on every offer card) 2026-07-21 06:25:14 +10:00
type-two
8edf461286 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-21 06:21:56 +10:00
type-two
3717ef0d54 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-21 06:21:16 +10:00
type-two
5ae696040d Lane D S17: [D] THREADS — convergence declared, the other D session's pool yard + cold-play adopted
Two Lane D sessions ran gates 3.2 + 4 in parallel; found via push rejection.
Their site_04_pool_yard + seven-night cold-play (515/0/0) STAND — dropped my
whole duplicate cut (a second, two-corroded-post pool yard + a second cold-play),
did not force, did not rebase over theirs. Verified their tree green (515/0/0)
before adding a line.

Additive-only: a measured receipt for their second-rust-post landmine (a real
two-post yard splits the exposure harnesses, exposureOf $115 vs
collateralExposure $45 — C's gate-1.2 cross going red by construction); and an
independent live repro of C's night-7 board-card omission (card "33 km/h · fine
pea stones" vs sheet "rain heavy · 44 mm/hr · hail likely") flagged as the #1
board fix. Scratch merge judged the wider tree (main + newest b/c) at 517/0/0,
per-lane sum verified, nothing lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:14:11 +10:00
type-two
d8466355e8 Lane D S17: [D] THREADS — the seven-night cold play through the board, verdicts per night
Two alternatives taken (the pool yard N1, the $45 cap N4), the marquee, the
designed loss and the fabric bet on the spine. THE WEEK HELD 7/7 · 6/7 · $401
· star 0.0 · 5 warranty jobs. Filed: the pinned wildnight recipe loses corners
in the played game (gardenfly says 0/4 — find the variable); the clean/marginal
boundary at the $5 tier sells lines the real game breaks; the designed-loss
night is dodgeable at zero premium (S18's saying-no question, now with a case);
same-client double offer reads odd on N7. Final tree 515/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 04:30:20 +10:00
type-two
4cdb2fead8 Lane B S17 gate 3: the corroded tier prices honestly in the card
The piece the 0.2/0.3/2 pass explicitly deferred. Pins E's corroded tier
through the card's own pricing surfaces, off E's real resolver (not a
hand-typed 0.55):

- STEEL half (rigging.selftest): factoryExtras('corroded_post') -> hint 0.55
  + collateral key; _effRating on a corroded corner is rating x 0.55, so the
  same load a carabiner holds on a sound post is over a carabiner on a
  corroded one. The rust costs a tier.
- COLLATERAL half (gardenfly.selftest, real dressed world): a node-less
  corroded placement -> collateralFor =  'the corroded post', summed by
  collateralExposure -- through the resolver, not the baked GLB value direct
  (the carport-to-null shortcut).

Cross-lane seam: the tier lives on lane/e, so both pins skip-with-disclosure
on bare lane/b (SKIPPED, visible in the report) and go live at integration.
Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
). Browser 485/3/0, node sail 47/47 rigging 29/29.

Test-only + THREADS; adopted the landed 0.2/0.3/2 unchanged rather than
overwrite pushed work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:59:53 +10:00
type-two
64fb25529f Lane D S17: [D] THREADS — gate 3.2 landing (the pool yard, receipts, the second-rust-post filing)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:53:46 +10:00
type-two
39701884e3 Lane D S17 gate 3.2: THE POOL YARD — authored cold, in the game only by being chosen
site_04_pool_yard: E's ring (fourteen honest noes) east, the bed poolside, ONE
corroded post in the venturi channel where the NE corner wants steel. Authored
in the editor (palette shapes, iterative SCORE IT x3, canonical export).
Gauntlet: WINNABLE at $80 — 13 quads, 6 clean, 1 marginal; cheapest hold $40
(h1,t1b,p2,p3, 92.7 FULL, no rust); every 92%-cover line routes through c1 —
clean $65 rated on t1,p1,p2,c1, UNHOLDABLE on the two shapes that pull the eye
harder. audit.html over the shipped file reproduces the card to the digit.
Separation REFUSED with receipts (bare 83.7 FULL — mild-night canon, A's 0.4).

board.js (A's granted edit): pool_yard_southerly POOL entry with the audit
receipt. d.test: +5 pins (chosen-never-scripted, ring/rust facts, judged-XOR,
wreck apron, exposure $160). gardenfly.selftest (B's file, flagged): judged
walk 3 -> 4 in the same commit that ships the yard.

Selftest 515/0/0 (510 + 5, the count moved by exactly what was added).
Mutations: 1 full-suite (finding stripped -> DOUBLE red: B's walk 'UNJUDGED'
+ my XOR pin, 513/2), 10 probed (entry deleted/re-paired/receipt stripped/
scripted-slot, gnome unpriced/rust zeroed/fence priced, apron x3) — every one
red with its own words, controls green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:53:20 +10:00
type-two
bb8451d12e Sprint 17 pt 1 integration: the board live (510/0/0), constraint seam verified at merge, [I] entry 2026-07-21 03:14:44 +10:00
type-two
3548b6edc1 Lane C S17 gate 1 (cont.): the OFFER BAND and the PAIRING law — night 7 stops lying
Landed ON TOP of f029274 with not one line of it changed. Two Lane C sessions
ran gate 1 in parallel; I found out by having my push rejected. forecastHonest
answers "can the forecast describe this STORM" and is better than the storm-side
half I had written, so mine is deleted. These are the two things it structurally
cannot reach — it takes a def, not a pairing, and it never touches the card.

weather.js is +180/-0. The only line of theirs I touch anywhere is widening one
c.test import from { NIGHTS } to { NIGHTS, nightAt }.

1. THE OFFER BAND, and the finding that motivates it. A's card prints f.wind
   and f.stones, and under that pair NIGHT 7 LIES:
     soaker (scripted)   gusts to ~55 km/h · fine pea stones
     early buster (alt)  gusts to ~76 km/h · pea stones
   On both printed lines the soaker is the softer job. It is not: 14.9s of hail
   against 2.4s, 44 mm/hr against 16.5. The soaker's whole design is that its
   wind reads gentle and its hail is the trap, so the board recommended the trap
   in the storm's own voice. offerBand(def, lead) returns an ORDERED LIST, so
   the forecast decides which facts a night carries and a card cannot drop the
   next one added — same construction as A's stormsToPreload(nights, pool).
   Verified in the real card on :8826.

   Second finding: storm_03 and storm_03b are identical in every number the band
   prints except the change time (30s vs 18s), and the board offers exactly that
   pair on nights 5 and 7. Without the change line it offers two indistinguish-
   able jobs and calls it a choice. Pinned.

2. PAIRING_LAW. A band can be perfectly honest about a storm and still be an
   invitation to a night nobody can work. A's three POOL exclusions were prose;
   promoted to checked data with the measurement in each message, credited by
   owner (the soaker's is mine with the S16 ~31% receipt; wildnight and icenight
   are recorded as A's reasons, not re-measured by me). Absent key = unrestricted
   deliberately — inventing a refusal for a pairing nobody flew is the same
   offence inverted, and that direction is pinned too. pairingRefusals is NOT a
   filter: silently dropping an entry is a runtime surprise wearing a fix's
   clothes. Walks NIGHTS u POOL carrying the SITE: 12 pairings, 0 refusals, with
   a vacuity guard in the same case.

Mutation-checked on THIS tree after the code changed address: hail line dropped
(RED, names the storm and the seconds), soaker restriction deleted (RED x2,
including the vacuity guard), change line dropped (RED, names the two busters).
All restored, 494/0/0.

Filed in THREADS, not landed: the hud/main hunk for A (leadFor precedent), and
a floor for stoneWord — hail.size 0 words itself "fine pea stones" because
stoneWord has no floor. Their STONE_WORD_CEILING fixes the top of that range;
nothing fixes the bottom. It belongs inside forecastHonest, which is theirs.

Selftest 494/0/0 (their 490 + 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:07:02 +10:00
type-two
44f019f31d Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-21 03:06:26 +10:00
type-two
4cfb227935 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-21 03:06:16 +10:00
type-two
895539da7c Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-21 03:05:43 +10:00
type-two
5befa54feb Lane B S17: [B] THREADS — gates 0.2/0.3/2 landed, mutation table, wiring line filed for A 2026-07-21 02:57:42 +10:00
type-two
1bcfcdad67 Lane B S17 gates 0.3 + 2: the card names its cloth, the session speaks for the client
GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding, A consulted on
wording and their every-card rule adopted). The audit always flew shade cloth
and never said so; on the soaker that silence read 'WINNABLE over an all-DEAD
garden — the night is broken'. Now: AUDIT.POROSITY is the one charter knob
(sweep.js + gardenfly.js both read it), scorecard.fabricCharter() names the
fabric and whether tonight's stones pass its weave (same hailBlockFor the sim
charges), and the SCORE IT header prints 'fabric · shade cloth (sweep
charter)' on EVERY card — plus, on leaky-storm nights, A's wording: the
fabric is the bet, and the audit does not place it.

GATE 2 — CLIENT CONSTRAINTS: A's shapes (seam contract, built against as
posted), this lane's teeth.
· rigging.js: validateNightConstraint (checked enum, both ends of the seam),
  setConstraints (survives reset like _startBudget; [] is the only clear).
  The house ban refuses the pick with the CLIENT'S WORDS as the ticker
  reason; the cap refuses at SPEND time on every path (rig / cycle / set /
  spares / fabric), exact at the boundary, refunds always free — and commit
  is belt-and-braces: a caller who walked around the doors gets a THROW.
  The cap covers the whole invoice, spares included — the client queries the
  invoice, not the hardware line.
· sweep.js: applyNightConstraints, ONE function both drivers share — bans
  shrink the candidate list (a card that recommends a forbidden line is the
  card lying), the cap re-prices affordable/clean without touching a load.
· scoreSite carries constraints/budget/constrainedOut; the card prints the
  client's terms next to funnel and fabric, and tells 'no geometry' from
  'client forbids all of it'.
· spent getter now measures against _startBudget (the re-banked session was
  measuring spend against the default bank).

Selftest 485/0/0 in the browser (474 + exactly the 11 added: 1 sail, 4
session, 2 charter, 4 audit-constraints). Wiring line for A filed in THREADS:
session.setConstraints(week.job?.constraints ?? []) at the night boundary.
2026-07-21 02:45:49 +10:00
type-two
aa0b36a54c Lane B S17 gate 0.2: THE SECOND WIRE — a fully-lost sail re-arms the heal
D's S16 poison poke, filed for this lane: _checkFailure's corner loop is the
heal's only trigger and it skips broken corners, so once all four were
divergence-broken a SECOND poison had no unbroken corner left to read NaN
through — the corpse stayed NaN forever and rendered as nothing. A lost sail
is still cloth in the yard; it must LOOK lost, not vanish.

The sentinel runs ONLY on a fully-broken rig (the corner wire covers every
other case): sum pos+prev — any NaN/±Inf makes the sum non-finite (Inf−Inf is
NaN), finite cloth cannot overflow it. Branch-free O(n) adds, no mutation, so
a finite lost sail is untouched byte-for-byte.

Test replays D's gait: cascade 4/4 → 5 s finite with ZERO heals (the negative
control — the sentinel must never mistake 'lost' for 'sick') → second poison
heals in one substep, finite a full second on. Node suite 47/47.
2026-07-21 02:45:28 +10:00
type-two
f029274940 Lane C S17 gate 1 + 1.2: the honesty gate on the board, and the exposure cross
- forecastHonest(def): "a storm the forecast can't describe honestly doesn't
  get offered" as a predicate, not a sentence — validator + measurable stats +
  the stone vocabulary ceiling (STONE_WORD_CEILING 2.0: past it, "golf ball"
  undersells the ice and the word ambushes where the band can't). The band-
  containment recomputes are disclosed as structural regression armour, not
  counted as coverage.
- c.test walks NIGHTS ∪ POOL from the shipped data (the union, per A's
  stormsToPreload finding — a pool-only storm is covered the day it exists).
  Negative controls: a lying windchange, a cricket-ball stone, both refused
  by name. Lead-0 card pins: the offer card's gust/sustained figures parsed
  back out and matched against stormStats; a lead-0 offer must not hedge.
- The exposure cross (the S14 pin pattern, third outing): my failure-route
  envelope (built world, anchor collateral keys through world.collateralFor,
  the gnome per scoreRun) vs A's exposureOf — item-level equality on ALL
  THREE yards: backyard 115, corner block 205, swing lawn 255. Zero unpriced
  keys. Teeth proven in-suite: strip the carport key and the cross reds.

Selftest 490/0/0 (A's 485 + 5), browser-verified; board verified live on
:8826 — both cards print the honest lead-0 lines and the crossed dollars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:39:07 +10:00
type-two
6e0608a6fb Lane E S17 gate 3.1: THE CORRODED TIER — mutations restored, tell verified at 20 m, droop pinned to geometry
Recovers an interrupted session and finishes its verification honestly.

RESTORED (the predecessor was killed mid-mutation-check):
- build_sail_post_corroded still had negative control M1 APPLIED — clean
  galvanised steel, DROOP=0 — so both shipped GLBs were the untelled post.
  Restored: steel_weathered/rust/rust_deep, DROOP_DEG = 38.
- asset_report.json held 1 of 37 assets and contact_sheet.png was a 196 KB
  truncated write. Both were interrupted artifacts, not decisions; a clean
  rebuild restores 37 assets and a 5.07 MB 1680x4200 sheet.

DETERMINISM: 37/37 GLBs byte-identical across two consecutive full runs;
report and contact sheet byte-identical too. Zero churn on the 35 pre-existing
GLBs.

ASSERT THAT COULD NOT FAIL, KILLED: the sag pin (honestY - corrodedY > 0.05)
reads 0.060 at ZERO droop, because the eye hangs off a weld line 160 mm down
the post — measured on the M-B build, anchor at (0, 3.920, 0). It measured the
weld offset and called it a sag. Replaced with the droop ANGLE derived from
the exported anchor and pinned to baked extras (padeye_droop_deg / arm_m /
weld_y_threejs / dir_threejs), all stated in three.js coords — the axis trap
closed the swing_set way, so note and mesh can only lie together.

ASSERT WHOSE REASON WAS BACKWARDS, FIXED: the 20 m pin claimed the corroded
shaft read darker. Measured off look.html's canvas at 20 m: honest lum 50,
corroded lum 94 — it renders twice as BRIGHT. Base colour is darker but
metalness drives the pixels (gal 0.90/0.35 goes near-black with no env map;
weathered 0.55/0.60 is diffuse). Test now pins both halves and names the
mechanism the player actually sees.

Three tells split into three tests so one failure cannot mask the others —
pre-split, the M-A mutation went red on rust area and never reached the
lightness or droop asserts at all.

Mutation-checked in the browser, all three witnessed red: M-A clean steel
(1/3 + 2/3 red, 3/3 green, factory GREEN throughout — the trap it cannot see,
confirmed); M-B flat eye (3/3 red, others green); M-C baked note lies while
mesh is honest (3/3 red, proving the pin reads the extra, not a literal).

Selftest 483/0/0 (474 baseline + 9 exactly). Tell verified by looking at
20 m at the game's own camera height, screenshots taken.

editor.js untouched — palette hunk filed verbatim in THREADS for A.
contracts.js ANCHOR_TYPE widening flagged for A.
Lane D flagged: the tier is placeable, gate 3.2 unblocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:57 +10:00
type-two
a180fce8c3 WIP Lane E S17 gate 3.1: corroded tier (PREDECESSOR STATE — negative control M1 still applied)
Preserved verbatim from the interrupted session. NOTE: build_sail_post_corroded
still carries the M1 mutation (clean galvanised steel, DROOP=0), so the two GLBs
in this commit are the UNTELLED post, not the shipped asset. Restored in the
next commit after the red-check is witnessed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:18:44 +10:00
26 changed files with 3193 additions and 48 deletions

View File

@ -8641,3 +8641,811 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· Verified: selftest **485/0/0** in the browser on :8824, my own tab, after the line —
count unmoved, correctly (a feature-detected no-op adds no asserts and changes no
behaviour on this tree; its behaviour is B's, already pinned at their end of the seam).
[C] 2026-07-21 — 🚪 **GATE 1 (C's half) LANDED: THE HONESTY GATE — "a storm the forecast can't
describe honestly doesn't get offered" is now a predicate and a walk, not a sentence in a
sprint doc.** A's seam contract adopted exactly as posted; `forecastLines(def, 0)` on the
offer card is the right call and nothing about it moved — lead 0 is tonight, both offers
are tonight, the board can never be more confident than the sheet (A's own line, now
pinned from my side too).
**`forecastHonest(def)` (weather.js)** — {ok, errors}, recomputed FROM THE DEF, nothing
trusts provenance (checkEnvelope's rule). What has TEETH, disclosed per the README's
decoration clause: the validator (a windchange the dirCurve never delivers, overlapping
gusts), the finiteness of the measured stats, and a NEW gate — **the stone vocabulary
ceiling.** `STONE_WORD_CEILING = 2.0`: the word map's biggest word is "golf ball" and it
is open-ended upward, so a def with hail.size 2.6 would print "golf ball stones" over
cricket-ball ice — the numbers would stay honest and the WORD would ambush, which is the
same lie band() exists to forbid. At 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball country;
shipped max is the icenight's 1.4. Past the ceiling a storm is UNOFFERABLE until the
vocabulary grows a word — a wording gate, not a data tweak. **Disclosed plainly: the
band-contains-truth and lead-0-exactness loops inside the predicate are STRUCTURAL today
(band() cannot exclude the truth by construction) — they are regression armour for the day
someone re-plumbs band(), and I am not counting them as coverage.**
**The walk (c.test): NIGHTS POOL, union taken from the shipped data.** A's
stormsToPreload mutation finding is the warning label on this one — today every POOL storm
also flies in NIGHTS, so a walk of NIGHTS alone would be green by coincidence and rot the
day D's pool yard brings a storm of its own. The union means that day changes the walk
without anyone editing a test. Negative controls in-suite: a flattened southerly (promises
a change, never turns) is refused naming the windchange; an icenight at hail.size 2.6 is
refused naming the vocabulary. Plus lead-0 CARD pins over every POOL entry: the printed
gust and sustained figures are parsed back out of `forecastLines(def, 0).wind` and matched
against stormStats' measured numbers, and `confidence` must be empty — a lead-0 offer that
hedges is waffle, and a card number that drifts off the measurement while both stay
individually green is the exact disease the repo keeps catching.
**The verdict on the shipped board: NO VETO.** All six authored storms pass; all five POOL
entries pass. A's deliberate absences agree with my measurements independently — soaker off
site_02 is my S16 ~31% hail-cover cap, wildnight/icenight off backyard_01 are a pinned
separation and a measured bed flag, exactly as A's file says.
**D, for the pool yard (gate 3):** your storm meets this gate at integration automatically
— it is data-driven off POOL. If it hails, keep the size ≤ 2.0 or bring me a wording
argument (I'll grow the map, but the word ships in the same commit as the stone). And
generated storms are already inside the tent: makeStorm(seed) soaker-family defs pass
forecastHonest for any seed I threw at it (6 seeds node-checked on top of the suite's
envelope proofs), so arc 4's seeded offers walk through the same gate the day they exist.
[C] 2026-07-21 — 🔩 **GATE 1.2 LANDED: THE EXPOSURE CROSS — two harnesses, one set of dollars,
and they agree TO THE DOLLAR on all three yards the board can currently offer.** A asked
for one crossed offer; all three were the same price to build, so the whole surface is
crossed and the day a number drifts the suite says which yard.
**My envelope is the FAILURE ROUTE, built the way the invoice bills:** a real world
(createWorld + dress, the a.test pattern), every collateral KEY reachable from a BUILT
anchor priced once per structure through `world.collateralFor` — the resolver scoreRun
uses at the moment a corner lets go — plus the gnome per scoreRun's two-corner rule. A's
`exposureOf` walks the site JSON up front and never sees an anchor; mine walks the built
world and never sees the JSON's structure list. Two routes, one number, by construction
(the S14 pin pattern, third outing).
| yard | both harnesses say | itemised |
|---|---|---|
| backyard_01 | **$115** | the gutter 90 + garden gnome 25 |
| site_02_corner_block | **$205** | the carport 180 + garden gnome 25 |
| site_03_swing_lawn | **$255** | the gutter 90 + the swing set 140 + garden gnome 25 |
Item-level equality asserted, not just totals — totals agreeing is necessary, not
sufficient, and two errors cancelling is precisely the coincidence class this pattern
exists to kill. The explicit dollar pins are the vacuity guard (two empty walks agreeing
on $0 cannot pass), and a zero-unpriced assert rides along: no anchor on any shipped yard
reaches a collateral key without a price — "not scored" must never become "free" on an
offer card, same law as the editor's exposure panel.
**The cross can fail, proven in-suite:** strip the carport key off the beams and my
envelope reads $25 against the card's $205 — which is the free-carport bug (S11S14) in
the board's clothes: $180 of advertised risk the failure path could never bill. That is
the drift this cross now catches structurally, on every run.
**A: your three numbers are confirmed from the failure side. No variable to find.**
Selftest **490/0/0** (A's 485 + these 5 — honesty walk, two negative-control gates, the
lead-0 card pins, the cross, the cross's teeth). No wind-panel polish this sprint — the
optional slot went to crossing all three yards instead of one, filed here with its reason:
a complete cross is worth more than a prettier panel the week the board ships.
[B] 2026-07-21 — 🔧 **GATES 0.2 + 0.3 + 2 LANDED: the heal re-arms on a lost sail, the card names
its cloth, and the session speaks for the client. Selftest 485/0/0 in the browser — 474 + the
11 added (1 sail, 4 session, 2 charter, 4 audit-constraints), the count moved exactly by what
was added. Eight mutations run: seven RED, one proved EQUIVALENT by an S16 contract and is
recorded as a finding, not waved through.**
**GATE 0.2 — THE SECOND WIRE (D's poison poke, my S16 filing, verbatim brief).** The corner
loop in _checkFailure was the heal's only trigger and it skips broken corners — so with all
four divergence-broken, a second poison had no unbroken load to read NaN through and the
corpse stayed NaN forever, rendering as nothing. The fix is a sentinel that watches ONLY on a
fully-broken rig (every other case stays on the corner wire): sum pos+prev, non-finite sum =
diverged (InfInf is NaN; finite cloth cannot overflow metres). Branch-free, no mutation, so
a FINITE lost sail is untouched byte-for-byte. The test replays D's gait: cascade 4/4 →
5 s finite with ZERO heals (negative control) → second poison heals in one substep, finite a
full second on. A lost sail now LOOKS lost — cloth on the yard — instead of vanishing.
**GATE 0.3 — THE CHARTER, SAID OUT LOUD (D's soaker finding; A consulted, and A's counsel
adopted on both counts).** A's wording beat the sprint's ("the F key" is a keyboard fact
leaking onto a results card; name what is MISSING), and A's rule is now the card's rule: the
disclosure prints on EVERY card, because a warning that only appears where somebody already
suspects a problem teaches that absence means safe.
· The charter number is ONE knob now: AUDIT.POROSITY (sweep.js), read by priceCandidate and
flyGarden both — it was hardcoded 0.30 in two files, which is how a charter drifts.
· scorecard.fabricCharter(stormDef): pure, resolves the fabric out of rigging's FABRIC by
the porosity actually flown, and computes `leaks` with the same hailBlockFor the sim
charges — C's weave ruling, one door, no second copy of the aperture math.
· **Verified on the glass, my own eyes, the exact scenario D filed** (site_02 × soaker,
scored on :8825): the header card now reads `fabric · shade cloth (sweep charter)` and, on
this storm whose stones pass the weave: "Scored on SHADE CLOTH, and tonight's stones pass
that weave — on this night the fabric is the bet, and the audit does not place it. The
membrane line is not in these numbers: a DEAD garden table below means the cloth loses,
not the night." Directly above ✓ WINNABLE at $80 over bare 17.5 DEAD — the silence D
flagged is gone. Pins: soaker leaks:true, icenight leaks:false, dry night silent, and a
guard that goes red if the charter knob ever drifts off a named fabric.
**GATE 2 — CLIENT CONSTRAINTS: A's shapes as posted (nothing invented), my teeth.** Built
against the seam contract mid-flight exactly as designed — fetched lane/a, read the shapes,
changed nothing about them.
· **The session** (rigging.js): validateNightConstraint — the enum checked at MY end of the
seam too (A's board validates the data source; a malformed constraint reaching the session
is a caller bug and THROWS, setHardware's class). setConstraints survives reset() like
_startBudget ("play again" replays the same client); [] is the only clear, hence the
wiring line below. The house ban refuses rig() with **the client's `says` verbatim as the
ticker reason** — the refusal is the client talking. The cap refuses at SPEND time on
every path (rig / cycleHardware / setHardware / setSpares / setFabric), exact at the
boundary ($45 spend under a $45 cap commits), refunds never blocked — a cap discovered
four corners deep is a restart wearing a rule's name. And commit() is belt-and-braces per
the sprint's own words: banned steel in the picks or spend past the cap THROWS, because
by then every player-reachable door has refused, so it can only be a caller bypass.
· **One ruling of mine, written down: the cap covers the whole spend, spares included.**
The client's words are "I'll be querying the invoice", and the spare is on the invoice.
$40 of steel + $15 spare under the $45 cap is refused — the constraint's own squeeze,
surfaced in the shop, and the CHEAPEST HONEST card already prints "no room for a spare"
when that bites. Contest at integration if the papers disagree.
· **SCORE IT honours the constraints** (a card that recommends a forbidden line is the card
lying): applyNightConstraints in sweep.js — ONE function, both drivers, because the
sync===async purity contract extends to which candidates exist. Bans drop every candidate
touching banned-family steel (including a pinned separation line if it does — the pin is
a fact about the YARD, tonight's card recommends for tonight's CLIENT); the cap re-prices
affordable/clean without touching a load (pinned byte-identical). scoreSite carries
constraints/budget/constrainedOut; the card prints ⚖ client terms in the header next to
funnel and fabric, headlines "at $45 (client cap)", and a yard the ban EMPTIES says so —
"client forbids all of it" is a different fact from "no geometry" and the render tells
them apart (constrainedOut is on the score for exactly that).
· Incidental fix while wiring the cap: `spent` measured against the global START_BUDGET,
not `_startBudget` — identical on every shipped night, a quiet lie the day they diverge.
**MUTATIONS — eight, each applied/run/reverted, messages recorded:**
| mutation | result |
|---|---|
| 0.2 sentinel disabled | RED — "the re-arm edge is back (_checkFailure has no wire…)" |
| 0.2 sentinel arms on finite state | **EQUIVALENT, and that is a finding**: _healNonFinite is pure repair and emits only when it healed (both S16-pinned), so over-arming is a no-op by contract — the sentinel's only observable job is the arming DIRECTION, which the first mutation kills |
| ban check out of rig() | RED — "h1 rigged under a house ban" |
| cap check out of cycleHardware | RED — "cycleHardware spent past the client's cap" |
| commit cap assert disabled | RED — "commit attached a sail spent past the client's cap" |
| fabricCharter leak flip (<1 <0) | RED "soaker reads leaks:false" |
| ban filter no-op in applyNightConstraints | RED ×4 — drop count, survivor candidates, emptied-yard pins |
| cap never reaches priceCandidate | RED — "a1,a3,a4,a5 still clean under the cap" |
(0.2/session mutations under the node twin of the same arrays the browser runs; audit
mutations under a probe mirroring the pinned assert expressions — and the browser harness
demonstrated red-on-failure for this exact test block earlier today, when my own vacuity
guard fired on the first run: the symmetric fixture prices every clean line identically, so
the cap test now derives its cap from the fixture's own price list and cannot go vacuous.)
**FILED:**
· **A — the one wiring line that is yours** (the setBudget pattern, same boundary):
`session.setConstraints(week.job?.constraints ?? [])` wherever main.js re-banks the shop
for the night. The `?? []` is load-bearing — it is what clears the previous client's
terms; the session deliberately keeps them through reset() so "play again" replays the
same client. Everything else on my side is live the moment that line lands.
· **A/D — SCORE IT's constraint door is programmatic only, on purpose**: the editor panel
passes none (an editor yard has no client — constraints are night data, not site data).
Constrained offers score through `scoreSite({ constraints })` — the board's receipts or
D's gauntlet can call it today; if S18 wants a UI door on the panel, that is a small A+B
seam, not a re-architecture.
· **D — gate 3 support is armed and DEFERRED as briefed**: your pool yard lands in your
second pass, after my run. When it does: scoreSite takes your entry's constraints if the
offer carries any, the charter line will name the cloth on your card, and separation
stays pinned-XOR-refused — my S16 refusal machinery is unchanged. Corroded steel prices
through ratingHint, which every sweep already reads live.
Pushed lane/b: aa0b36a (gate 0.2), 1bcfcda (gates 0.3+2), this entry. Port 8825, own tab
throughout; the full suite green in the browser before every push.
[E] 2026-07-21 — 🦀 **GATE 3.1 LANDED: THE CORRODED TIER — and a recovered session's two live mutations.**
I inherited this gate mid-verification (predecessor killed by an API limit on the words "Factory
is green on the untelled post — the trap it can't see. Browser now."). **It had not finished the
sentence it was in the middle of, and the tree said so.**
**WHAT I FOUND UNCOMMITTED — read the diff before trusting it, and this is why:**
· `build_sail_post_corroded` still carried its **negative control M1 applied**: `steel =
Mat_Steel(steel_gal)`, `rust = steel`, `rust_d = steel`, `DROOP = radians(0.0)`. Both shipped
GLBs in the tree were therefore **the untelled post** — clean galvanised, flat eye — not the
asset the docstring described. Committed verbatim first (`a180fce`, labelled WIP) so the
predecessor's state survives in history, then restored.
· `asset_report.json` held **1 of 37 assets**; `contact_sheet.png` was **196 KB / truncated**
(against 4.77 MB on main). Both were interrupted mid-write, not design changes. A clean
rebuild restores 37 assets and a 5.07 MB 1680×4200 sheet (one extra tile row for the two new
props). *The 886-line "deletion" in that diff was a corrupt artifact, not a decision.*
**Rebased a180fce onto 75d5f9a (D's seven nights) — zero conflicts, as predicted: week.js /
a.test / d.test never meet my files.**
**DETERMINISM (my own runs, not inherited):** five full `blender -b -P build_yard_assets.py`
runs. **37/37 GLBs byte-identical** across two consecutive clean runs; `contact_sheet.png` and
`asset_report.json` also byte-identical run-to-run. **Zero churn on the 35 pre-existing GLBs**
only `sail_post_corroded_v1.glb` moved, and `sail_post_corroded_wrecked_v1.glb` held hash
`77ba2173…` unchanged through every mutation (I only ever touched the intact builder — the
wreck's stability is the control that proves it). Shipped: intact `413e8da4…`, wreck `77ba2173…`.
**MUTATION CHECKS — three, each witnessed red in the real harness at :8828, not reasoned about:**
· **M-A clean steel** (gal everywhere, rust aliased to steel): TELLS 1/3 red *"rust covers 0.0%
of the corroded post's surface"*, TELLS 2/3 red *"corroded shaft lightness 0.737 vs galvanised
0.737"*, **3/3 green** (droop is independent of materials). **The factory itself stayed GREEN
on all four dims/nodes checks** — the predecessor's claim is CONFIRMED: the untelled post is
exactly the trap the factory cannot see, and e.test is the only thing that catches it.
· **M-B flat eye** (`DROOP = 0`, steel restored): TELLS 3/3 red *"the exported pad eye droops
0.0° but the root claims 38°"*, **1/3 and 2/3 green**.
· **M-C the note lies, the mesh is honest** (baked `padeye_droop_deg = 20`, geometry left at 38):
3/3 red *"droops 38.0° but the root claims 20°"*. The pin reads the **baked extra**, not a
literal — so note and mesh can only ever lie together.
**AN ASSERT THAT COULD NOT FAIL, KILLED.** The WIP pinned the sag as `honestAnchorY
corrodedAnchorY > 0.05`. The eye hangs off a weld line 160 mm down the post, so that quantity is
**0.060 m at ZERO droop** — I measured it on the M-B build: anchor at `(0, 3.920, 0)`, no
horizontal displacement whatsoever, and the old assert **passes**. It was measuring the weld
offset and calling it a sag. Replaced with the droop **angle** derived from the exported anchor
(`atan2(horiz, y weld_y)`), pinned to a baked extra — plus arm length and direction. **THE AXIS
TRAP, closed the swing_set way:** the builder now bakes `padeye_droop_deg 38`, `padeye_arm_m
0.08`, `padeye_weld_y_threejs 3.84`, `padeye_droop_dir_threejs "+Z"` — all stated in **three.js**
coords (Blender y → three.js +Z), and e.test recomputes every one of them off the geometry.
**AN ASSERT WHOSE REASON WAS BACKWARDS, FIXED.** The WIP's 20 m assert said the corroded shaft
"reads darker at yard distance". **I measured the actual render before endorsing it and the sign
is inverted.** Sampled off look.html's canvas at cam z=20 m, 1280×720, mid-shaft band:
honest rgb(49,51,49) lum 50 sat 0.031 RB 0
corroded rgb(98,94,86) lum 94 sat 0.126 RB +12
The corroded shaft renders **twice as bright**, not darker. Base colour *is* darker (0.524 vs
0.737) but it isn't what reaches the screen: galvanised is metalness 0.90 / roughness 0.35, and
with no environment map a near-mirror metal has nothing to mirror and goes near-black; weathered
at 0.55 / 0.60 is diffuse and catches the sun. **The separation is real and large — it is made of
METALNESS, not lightness.** The test now pins both halves and says which one the player sees; an
assert whose stated reason is the reverse of the mechanism is a docstring waiting to be "fixed"
in the wrong direction. Footing stain at the same range: sat 0.062 → 0.147, warmth +6 → +18.
**THE TELL READS AT 20 M — verified by looking, at the game's own camera height (1.7 m), in the
game's own renderer, screenshots taken.** Honest post: a clean dark line on a white footing.
Corroded: a mottled warm-grey shaft, rust collars at base and head, cap askew, and a **brown
footing** — the stain is the single most legible thing about it at distance. Close read at 4.5 m
confirms blooms, streaks, the deep-rust fold band, and the drooped eye. Wreck read at 7.5 m:
plumb stub with a torn rim, shaft out in the yard, pad eye on the grass at the far end.
**THE NUMBERS — I OWN THEM AS PROPOSALS, A RULES.** I **endorse 0.55 unchanged**, and my
argument is not the predecessor's. Ladder from the manifest: 0.22 carport < 0.30 carport_post <
0.35 house < 0.45 swing_frame < **0.55** < 0.65 pergola < 1.00 post. Above the swing frame
because a real footing beats sound steel loose on grass; below the pergola because the bloom
outside is pitting inside and you de-rate what you cannot inspect — both sound. **What I'd add:
this is the only rung on the ladder whose true capacity is UNKNOWABLE by inspection.** Every
other weakness is legible (the carport's thin bracket, the swing's missing footing, the pergola's
flexing deck). So the exact midpoint of 0.45→0.65 is right *because* it is the rung you cannot
reason your way to: at 0.55 neither "treat it as the swing frame" nor "treat it as the pergola"
is obviously correct, and the player has to **gamble** rather than compute. That is what a trap
tier is for. **Collateral $45 endorsed** — cheapest structure on the board (gnome 25 <
**corroded_post 45** < gutter 90 < pergola 120 < swing 140 < carport 180 < glasshouse 320), and
the reasoning holds: you are billing the make-safe (cut the fold, core-drill the stub), not a
rebuild, because the steel was condemned before the player arrived. **A — one caution:** $45
makes this the cheapest thing in the game you can break, so if playtest shows players *farming*
it as a cheap sacrificial anchor, the correction is the **sail HP and garden exposure** it costs
mid-storm, **not** the invoice line. Raising the bill would teach "don't touch rust"; the lesson
is meant to be "rust is a bet, and here's the stake".
**Verified:** selftest **483/0/0** on :8828, my own tab, fronted. Baseline 474 (D's seven-night
landing) **+9 exactly**: 4 factory dims/nodes rows for the two new GLBs, plus TELLS 1/3, 2/3,
3/3, the RUNG test and the wreck test. The count moved by precisely what was added.
*(Note for whoever scores next: my tab was silently backgrounded by another lane's tab mid-run
and the suite slowed to a crawl — results stayed correct, because selftest.html is fixed-dt and
says so in its own header, but budget for it. `tabs_context` shows who holds the front.)*
**⚠️ A — ANCHOR_TYPE WIDENED (contracts.js is yours; swing_frame/pergola precedent).** I added
`'corroded_post'` to the frozen enum with its reasoning beside it. Revert-and-tell-me standard.
Your own "the widened enum is CHECKED, so a bad type cannot ship" test covers it and is green.
**📋 A / INTEGRATOR — EDITOR PALETTE HUNK, APPLY VERBATIM** (editor.js is A's file; leadFor
precedent, seventh application). Insert as a new entry in the palette array, next to the pergola:
```js
{
// SPRINT17 gate 3.1, E. The corroded tier — the pool yard's other half.
// The pool kit is fourteen tie-offs that DON'T exist (every fence post an
// honest no); this is the one that DOES exist and shouldn't be trusted.
// 0.55: above the swing frame (a real footing beats loose-on-grass), below
// the pergola (the bloom outside is pitting inside — you de-rate what you
// can't inspect). It is the only rung whose real capacity you cannot read
// off the object, which is why it sits at the midpoint of 0.45→0.65.
// ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does
// `rating_hint ?? 1`, so a missing model does not fail — it becomes the
// BEST STEEL IN THE GAME. A typo here rates the corroded post 1.00 and the
// trap inverts into the safest anchor in the yard. (S14, D's cold pass.)
kind: 'structure', label: 'Corroded sail post (the trap that stands up)',
model: 'sail_post_corroded_v1',
requires: ['corroded_post'],
hint: 'looks like a post, rates 0.55 — rust at the base and head, and the pad eye has sagged',
make: (id, x, z) => ({
id, model: 'sail_post_corroded_v1',
wreckedModel: 'sail_post_corroded_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
collateralKey: 'corroded_post',
collateralValue: 45, collateralLabel: 'the corroded post',
anchors: [
{ id: `${id}_a1`, node: 'top_anchor', type: 'corroded_post', work: 'cloth' },
],
}),
},
```
**PLACEMENT FACTS, three.js coords (measured off the exported GLBs, not off the builder):**
· `sail_post_corroded` — bbox min `(0.253, 0.020, 0.260)` max `(0.253, 4.032, 0.260)`;
footprint **0.51 × 0.52 m**, height **4.03 m** (stands POST height on purpose: its lie is
"I'm a sail post"). Single anchor `top_anchor` at **(0, 3.903, +0.049)** — 0.077 m below the
honest post's 3.980 and displaced **+Z**, the yard side, because the eye has drooped 38°.
`rake_pivot` present and live, same mechanism as the honest post: it rakes identically.
· `sail_post_corroded_wrecked` — bbox min `(0.525, 0.020, 0.260)` max `(0.253, 0.682, 3.818)`.
**WRECK APRON: reserve ~3.9 m on +Z and ~0.6 m on X of clear ground** — the fallen shaft
lies out into the yard and will interpenetrate anything placed there. Max height 0.682 m.
**No anchor survives the wreck** (asserted): an anchor that outlives its structure is the
free-failure bug in a costume, fascia rule, fifth application.
**🌊 D — THE TIER IS PLACEABLE, YOUR GATE 3.2 IS UNBLOCKED.** `sail_post_corroded` /
`..._wrecked` are landed, deterministic, and manifest-resolvable **node-less** (type
`corroded_post` → 0.55, collateral `corroded_post` → $45), so a pool-yard site JSON can place
them with a plain entry and no `node` field and the rating still lands — you do not need to wait
on A applying the palette hunk above to write the site. Anchor node is `top_anchor` if you do
want it explicit. Mind the wreck apron (+Z ~3.9 m) when you lay out the pool fence: the corroded
post falls INTO the yard, which is the whole point of putting it where a corner wants to go.
[I] 2026-07-21 — **SPRINT 17 PART 1 INTEGRATION (main): THE BOARD IS LIVE.** Four lanes merged
(A board+rulings, B enforcement+heal+charter, C honesty gate+exposure cross, E corroded
tier); selftest **510/0/0** on the integrated tree, the exact per-lane sum (474 baseline
+ A 11 + C 5 + B 11 + E 9), verified in the browser. Merge order a→c→b→e; the ONLY
conflicts were three THREADS append regions, resolved keep-both — zero code conflicts,
one better than S16's one.
**The seam that closed in flight:** B filed the one wiring line as A's
(`session.setConstraints(week.job?.constraints ?? [])`); A landed it at the setBudget
boundary (99345f4), feature-detected so lane/a stayed green pre-merge. Verified live on
the integrated tree at merge, per A's own one-minute check: took the Vasilaros $45-cap
callout off the board, rigged to exactly $45 (allowed — exact at the boundary, B's
ruling), then one dollar past on BOTH the hardware-cycle and the spare paths →
`{ok:false}` with the client's words verbatim as the reason. The guard stays (it is one
`?.` and it documents its own interim honestly).
**Process note, recorded because it worked:** origin/lane/a already carried a complete
board implementation from an earlier Lane A run when this sprint's Lane A went to push
its rival draft — the fork was resolved by adopting the posted work as canon (it was
the contract B/C/D were already building against) and landing only what it missed. The
lane model's push-early rule did exactly what it exists to do.
**Next: D's second pass** — the pool yard authored cold (E's corroded tier is merged and
placeable), entered into the board's alternative POOL, then the full seven-night cold
play through the board (at least one alternative offer, one constrained job). Gate 4
(John plays the seven at partly.party) closes the sprint; deploy fires after D's pass
integrates.
[D] 2026-07-21 — 🌊 **GATE 3.2 LANDED: THE POOL YARD — authored cold, priced by the gauntlet,
refused honestly, and in the game only if somebody chooses it.** Selftest 515/0/0 (510 + 5
pool-yard pins, the count moved exactly by what was added). The first yard with no NIGHTS
slot: `pool_yard_southerly` is a POOL entry in board.js (A's granted edit) and nothing else —
decline it all week and the campaign is complete without it, which is arc 2's promise made
literal.
**THE YARD (site_04_pool_yard, "The Pool Yard", the Karalis place):** E's pool ring east —
fourteen perfect posts, every one an honest no — the bed poolside, and ONE corroded post
(c1, 0.55, $45) standing in the venturi channel between the house's east end and the pool
enclosure, exactly where the bed's NE corner wants steel. Honest steel is scarce and west
(gum t1/t1b, posts p1-p3, the familiar 0.35 fascia). Authored cold in the editor — palette
shapes, editor placement, iterative SCORE IT, canonical exportJSON; three passes (first cut
made TWO in-band quads — anchors spread past the 18-45 m² band; the band is a level-design
law I now have a feel for).
**THE NUMBERS (SCORE IT card = audit.html over the shipped file, zero delta between the two
harnesses):** WINNABLE at $80 — 13 quads in band, 6 clean, 1 marginal. Cheapest hold $40
(h1,t1b,p2,p3 → 92.7 FULL, 58% cover, no rust in it). **Every 92%-cover line routes through
c1 — full shade in this yard has rust in it or it does not exist.** And the tier does what E
built it for: the SAME eye flies 1.9 kN (clean, $30 rated) on t1,p1,p2,c1 and 3.6-4.1 kN
(UNHOLDABLE at any price) on t1,t1b,p2,c1 / t1b,p1,p2,c1 — swap one gum anchor and the
gamble flips, and nothing on the object tells you which. **HOW CORROSION PRICES: +$25 of
hardware you must rate up (a $5 carabiner corner becomes a $30 rated corner), a $45
collateral stake, and a shape-sensitivity you cannot read off the post.** Fence compliance
prices as ABSENCE: the ring contributes zero anchors and zero exposure — the offer card's
$160 (gutter 90 + rust 45 + gnome 25) deliberately does not name the fence, because "not
scored" must never read as "free" and nothing can bend a pool fence yet (bike rule).
**SEPARATION: REFUSED, third in the family, receipts in the site file** — bare 83.7 FULL
vs best flown 92.7 (+9.0): bareMustLoseBelow cannot be written; the southerly cannot kill
an open bed. First refusal authored AFTER A's 0.4 ruling made the pattern canon, and this
yard IS that canon on purpose: the sail here is for the steel and the collateral. Flagged
for B: when the garden-BONUS separation shape lands, this yard's +9.0-at-$40 is a
bonus-stake candidate — re-measure then.
**⚠️ FILED — THE SECOND-RUST-POST LANDMINE (E/A, before any yard ships two):** two
corroded posts in one yard SHARE the baked `collateral: 'corroded_post'` key.
structFor/wreckStructure match the FIRST entry with the key and C's failure envelope
dedupes keys — so the second post's failure would bill once and wreck the wrong post
(free-failure bug in a rust costume). The pool yard ships ONE post on purpose and pins it
(d.test). A second rung in one yard needs per-instance key plumbing first.
**PINS (+5, d.test):** chosen-never-scripted (no NIGHTS slot, POOL entry with receipt,
southerly = the taught storm) · ring adopts nothing + unpriced-by-ruling, rust adopts once
at type corroded_post/$45/wreck wired, exactly ONE rust post · separation-judged XOR with
the receipts' own numbers · wreck apron threads the needle (bed east 2.5 ≤ shaft west
2.84; shaft east 3.36 ≤ ring west 3.7 — E's +Z 3.9 m apron lands in the path, in the
yard) · exposureOf = $160 with exactly three items and the fence in none of them.
Cross-lane, flagged (revert-and-tell-me): gardenfly.selftest's judged walk grew
site_04_pool_yard and its count moved 3→4 in the same commit that ships the yard — a yard
missing from that walk is unjudged SILENTLY, which is the silence the gate forbids.
**MUTATIONS — 1 full-suite, 10 probed, every one red with its own words:** the finding
stripped from the site file ran the REAL suite to **513/2** — B's extended walk red
("site_04_pool_yard has neither a separation block nor a _separation_finding — the yard
is UNJUDGED") AND my XOR pin red ("silence is not an option") — the double tooth proving
the walk extension covers the new yard. Probed against mirrored assert expressions
(B's S17 precedent, disclosed): entry deleted / re-paired to the wildnight / receipt
stripped / scripted slot added; gnome unpriced ($135) / rust zeroed ($115) / fence
priced ($460 — the bike-rule breach reads as red, not as revenue); c1 nudged into the
ring apron (3.86 > 3.70) / bed widened under the shaft / c1 falling over the south
fence. Controls green on the shipped data.
[D] 2026-07-21 — 🎮 **GATE 3.2/4-ADJACENT: THE COLD PLAY — seven nights THROUGH THE BOARD, two
alternatives taken, one constrained, the marquee, the designed loss and the fabric bet all
played by hand. Final tree selftest 515/0/0. The week ends THE WEEK HELD · 7/7 nights ·
6/7 gardens · $401 · ★ 0.0 · 5 warranty jobs — and that end card holding "every savable
garden made it" against "you broke things all week" in one breath is the game's honesty
working at week scale.** Real path throughout: splash → board → take → sheet → rig by
clicking → Enter → storm at SHADES.step(1/60) (the repo's own fast-forward) → invoice.
Harness disclosure, said loudly: the prep camera is STATIC per night, so on three nights
one or two anchors sat outside the fronted pane's view and RMB-orbit is a pointer-capture
drag the pane tool cannot do — those picks (p1/p3 night 5, p1 night 6, q1/q4 night 7) went
through rigging.session.rig()/setHardware(), the exact calls the click handler makes.
Every other pick, every cycle, every refusal test, F, S and Enter were real UI events.
**THE NIGHTS, verdicts per night:**
· **N1 — TOOK THE ALTERNATIVE: my pool yard, cold off the board.** The card sold it like a
tradie reads a site: $57 vs the Hendersons' $42, $160 exposure vs $115, same storm band.
Flew the gamble line t1,p1,p2,c1 at $65+spare. The rust corner CARRIED it — c1 peaked
2.23 of its derated 3.58 — and the quiet truth is the night's real near-miss was the
honest $5 carabiner at p2 (peak 1.12/1.2). Every corner held, garden 93 (audit said 92.0
flown — played number 1.3 over, same ballpark), +$79, ★3.0→3.5. **Verdict: the trap
tier plays as designed — I KNEW the rating and still sweated the eye through the change;
a cold player reading rust + fence + funnel gets an honest fright with a fair tell.**
· **N2 — spine, southerly × backyard.** Shackles on the post quad, held 4/4, 89, +$81.
The board's alternative that morning (the Delaneys' house-ban at +25%) declined —
declining cost nothing, which S18 still owes an answer for.
· **N3 — spine, earlybuster × corner block.** Flew the audit's OWN cheapest hold
(tr1,tr1b,q1,q3 $40, tiers as printed) — and tr1b's $5 carabiner LET GO at 1.17 of 1.2,
inside the bench-vs-game under-read MANUAL carries as policy. Garden 88, +$88, but
★4.0→3.5. ⚠️ **Filed for B/A: the card called this line CLEAN. The 15% margin rule is
applied to the flags, but the clean/marginal boundary at the $5 tier still sells a line
the real game breaks — third data point (S13's session bypass, S16's soaker header,
now this). Maybe cleanHw should carry the margin policy the flags already know.**
· **N4 — TOOK THE CONSTRAINED JOB: the Vasilaros $45 cap, same yard and storm as N3.**
The invoice-back-to-back repeat COULD have felt like a bug and instead felt like
Tuesday work: same carport, same buster, the client now watching the bill. B's
enforcement, tested at both doors in play: cycling q3 up ($50) REFUSED, spare ($55)
REFUSED, the client's "Anything over forty-five and I'll be querying the invoice"
verbatim in the ticker both times. And here is the design PAYING: N3 taught me where
the loads live, so under the cap I did not buy up — I MOVED the tiers (shackles to q1
and tr1b, carabiners to tr1/q3, $40) and the same quad that broke last night held 4/4.
Garden 87, PAID UNDER THESE TERMS on the invoice, +20% fee, N3's carabiner as a
warranty line, +$104 — the week's best night, under the tightest terms.
**Verdict: the cap does not read as a rule; it reads as a client who was burned once,
because refusing hardware in THEIR words while paying +20% is exactly what that client
would do. And "the cap stops you buying your way out of choosing" is TRUE in the hands,
not just in the doc: knowledge became the only spendable currency.**
· **N5 — spine, THE WILDNIGHT (my gate-0.1 restoration, first full play at seven).** Rigged
the yard's own pinned separation recipe verbatim (p5 rated, p1/p2/p3 shackle, $75, no
room for a spare). The marquee delivered the canon: p2 blown at the change (3.48/3.2),
p3 followed (3.9), garden 56 — OVER the win line on two corners — gnome billed on the
two-corner rule, +$24, ★4.0→2.0. "THE GARDEN MADE IT. The sail didn't. That is what the
sail was for" over a $24 morning is the best sentence in the game.
⚠️ **Filed for B (numbers, not a fix): the PINNED RECIPE loses corners in the played
game.** gardenfly's pin holds 0/4 lost; my hand play of the same line, same storm, same
tension lost p2 then p3, peaks 3.48/3.90 vs shackle 3.2. Two harnesses, one number —
find the variable before tuning (my candidate: hail impact load reaches the rig only in
the game chain; debris was 0). Garden-side the pin's INTENT held (56 > 50, bare loses).
· **N6 — spine, the icenight, the designed loss.** Garden 0 "beyond saving from the
start", fee docked to 35%, N5's two warranty shackles billed no-charge-to-client,
$83, ★→0.0 — and the invoice SPLITS the blame: "The shackle at P2 letting go is the
part that's on you." The designed loss reads as designed, not as unfair, because the
paper says which part was the ice and which part was me.
· **N7 — spine, the soaker, the F key.** $75 ring rated-on-q1, membrane. q2 — the pond
corner, the broom's job — let go LATE at 3.32/3.2, exactly the corner and failure mode
C measured for a broom-less run (this play is that acceptance, on the record). Garden
96 FULL, "held 391 kg of what it stopped. That is the membrane's price", +$28.
**THE TWO QUESTIONS, answered as the playtester:**
· **Does choosing feel like running a business? YES — at the morning table, and it is the
strongest new feeling in the game.** Fee vs exposure vs storm band vs whose terms — I
caught myself doing tradie arithmetic ("$57 with a $160 yard against $42 with a $115
one, same weather") before the sheet ever opened. The safe job priced DOWN (the $44
retainer against $59 under the carport) made saying yes to risk feel like MY margin
call, not the script's. What does NOT yet feel like a business: saying no. Declining
costs nothing, so the board is a free option — and twice it offered a dodge that
undercuts the campaign's own set-pieces: **the wildnight was declinable for a Tuesday
southerly at similar money, and the icenight (the designed loss!) sat next to a
winnable constrained job at the SAME $68.** A fee-maximizer skips the week's two
hardest lessons and never knows. That is S18's declared question ("does saying no cost
you") and gate 4 should aim John straight at it — my vote from the chair: a declined
BOOKED night should cost standing (you were booked; the callout was the option), while
a declined callout stays free.
· **Does a constraint read as a client or a rule? A CLIENT, cleanly.** The words do the
work: the refusal is THEIR sentence at the moment of the pick, the premium is on the
offer where the choosing happens, and both papers repeat the terms. The one seam:
N7's board offered the same client twice (soaker × site_02 booked, southerly ×
site_02 callout) under "the other goes to somebody else" — two Vasilaros jobs where
"somebody else" is... the Vasilaros place. Cosmetic, filed for A with a smile:
eligibleAlternatives might also skip same-CLIENT-same-YARD pairs, or the line could
soften on a same-client morning.
**Small findings, filed:** pip glyphs are a quiet triumph (●●●●●○◆ tells the whole week
at a glance — held, held, held, held, held, LOST, tonight); the N3→N4 warranty chain
across a chosen night billed correctly both directions (S16's promise surviving arc 2 is
worth saying out loud); fees carry standing multipliers down to ×0.88 at ★0.0, so the
rep bleed is FELT in the wallet, which is REP doing its job unmeasured or not.
[D] 2026-07-21 — 🤝 **CONVERGENCE, DECLARED FIRST: TWO LANE-D SESSIONS RAN GATES 3.2 AND 4 IN
PARALLEL. I found out by having my push REJECTED. The pool yard (site_04) and the seven-night
cold play landed ABOVE are the other session's, and they STAND — I did not force, I did not
rebase over them, and I dropped my whole duplicate cut (a second pool yard + a second
cold-play).** Their pool yard is the better one and for the exact reason this convergence is
lucky: **it ships ONE corroded post on purpose to dodge the second-rust-post landmine — the
one my cut walked straight into.** I authored TWO corroded posts (the "different sets" thesis
read to me as needing a genuine choice of rust corners), scored it green, and only at exposure
time did the landmine they filed bite. So I bring their filing a measured receipt and nothing
else it doesn't already have. Verified their tree with my own eyes before adding a line:
selftest 515/0/0 on :8827, my own fronted tab.
**THE RECEIPT FOR THEIR SECOND-RUST-POST FILING (E/A — a number for when per-instance keys get
plumbed).** On a real two-corroded-post yard (both structures baking `collateralKey:
'corroded_post'`), I measured the two exposure harnesses in the browser and they DISAGREE:
· A's `exposureOf(siteJson)` (the OFFER CARD) reads **$115** — corroded $45 + corroded $45 +
gnome $25, per STRUCTURE.
· B/C's `collateralExposure` (SCORE IT / the failure route) reads **$45** — the corroded post
$45 ONCE (de-duped by key), and the gnome not counted at all (no anchor reaches it).
That is C's gate-1.2 exposure cross going RED by construction — the cross WORKING, on the first
yard that would trip it. The other session avoided it by shipping one post; the number is here
so the fix (per-instance key plumbing + whether the gnome belongs in `exposureOf`) has a datum,
because arc 2's glasshouse/granny-flat yards will want more than one priceable hazard someday.
**A SECOND PLAYTESTER'S TOP BOARD FIX, from an independent seventh night: the offer card LIES
about the soaker, live, exactly as C measured.** I took the N7 soaker off the board and read
the card against the sheet it leads to:
· board CARD: `sustained to 9 m/s (33 km/h) · fine pea stones` — reads as the SOFT job (the
alternative buster card reads 76 km/h, windier).
· job SHEET (one click later): `rain heavy · southerly change at 40s · hail likely · rain to
44 mm/hr`.
The card hides the 44 mm/hr downpour and the hail that ARE the soaker's trap, so on the board
alone the trap reads as the safe pick — the board recommending the trap in the storm's own
voice. C's `offerBand` hunk (hail duration + rain rate + change, mapped so a dropped fact can't
hide) is FILED and NOT INTEGRATED. **From the chair: this is the #1 board fix for the next
integration** — it is the one place the board is dishonest, and it is dishonest on the week's
signature trap. Everything else on the card (client, site, wind, fee, exposure, ⚖ terms) is
honest and load-bearing; the exposure line in particular is what a broke wallet reads to stay
off the carport.
Everything else my pass would have said, the landed cold-play above already says better (it
mouse-rigged where mine went through `session.rig` for want of a pointer-capture orbit, it
took the pool yard AND the $45 cap as its alternatives, and its declining-costs-nothing and
$5-tier clean/marginal findings are sharper than anything I had). No duplicate lands. My
scratch merge judged the wider tree (main + the newest lane/b + lane/c) at **517/0/0**,
per-lane sum verified, nothing lost in the merge — flagged for the integrator as the number to
reproduce when b/c's newest commits and this pool yard all land together.
[B] 2026-07-21 — 🦀 **GATE 3 LANDED: THE CORRODED TIER PRICES HONESTLY IN THE CARD — the piece the
0.2/0.3/2 pass deferred, now pinned. Plus: I re-ran 0.2/0.3/2 independently and they STAND.**
A recovered/parallel session (this one) rebuilt gates 0.2/0.3/2 from 75d5f9a before finding the
landed work on origin/lane/b. Rather than overwrite three pushed, receipted, mutation-checked
commits, I **reset to the landed versions and added only gate 3** — the one thing the earlier
pass explicitly filed as DEFERRED ("gate 3 support is armed and DEFERRED as briefed… Corroded
steel prices through ratingHint, which every sweep already reads live"). The parallel
reimplementation is kept in a local ref for reference; nothing from it overwrote the landed
lane/b. selftest still green (node sail 47/47, rigging 29/29 = 27 + the 2 gate-3 pins).
**What gate 3 pins**, off E's REAL resolver, not a hand-typed 0.55:
· **The STEEL half** (`rigging.selftest`): `factoryExtras('corroded_post')` → hint 0.55 +
collateral key, and `_effRating` on a corroded corner is `rating × 0.55` — so a load a
carabiner holds on a sound post (1200 N) is OVER a carabiner on a corroded one (660 N). The
rust costs a tier, measured, and if it EVER prices as sound steel the trap inverts into the
safest anchor in the yard (E's load-bearing `_v1` warning).
· **The COLLATERAL half** (`gardenfly.selftest`, a real dressed world, ~40 ms): a node-less
corroded placement (E's "plain entry, no node field" case — the harder one, rating and
collateral both from the factory) → `world.collateralFor('corroded_post')` = $45 "the
corroded post", summed by `collateralExposure` — NOT the baked GLB value read directly, the
shortcut that priced the carport to null for five sprints.
**Mutations** (run on the scratch merge where the tier is live): `collateralFor` drops the
price → card reads null; `factoryExtras` returns hint 1 → the steel pin reddens. Both caught.
⚠️ **CROSS-LANE SEAM, disclosed.** The corroded tier (0.55, the GLB, the gen entry) is on
lane/e. On bare lane/b `factoryExtras('corroded_post')` is null, so **both gate-3 pins
SKIP-WITH-DISCLOSURE here** and go live at integration — the "build against the shape as
pushed" pattern. Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
$45). Browser suite on bare lane/b: 0 failures, the corroded pins visibly skipped with their
reason in the run.
**D — your pool yard's corroded corners price right the moment you place them**, node or
node-less; the card names the $45 collateral and the 0.55 steel with no round-trip to me. The
tier is E's, the pricing surfaces are pinned, and the pin activates itself when your yard and
E's tier meet on the integrated tree.
**On 0.2/0.3/2 (the landed pass): reviewed, sound, nothing to change.** One optional
strengthening I found while reimplementing, filed NOT forced — the 0.2 negative control's
"over-arming is EQUIVALENT" finding is real (the heal is pure repair and emits only when it
healed, so an over-arm is a silent no-op to an event-counter). It IS observable if the control
spies the heal CALL rather than the event: a stub that arms on finite state calls
`_healNonFinite` every substep of a fully-broken-but-finite sail. If a future pass wants to
close that mutation gap, a `let calls=0; wrap _healNonFinite` in the finite-lost-sail control
turns the recorded "equivalent" into a red. Not this landing; the current control is honest
about what it does and does not catch.
[C] 2026-07-21 — 🤝 **CONVERGENCE, DECLARED FIRST: TWO LANE-C SESSIONS RAN GATE 1 IN PARALLEL,
and I found out by having my push REJECTED. What follows is landed ON TOP of `f029274`
with NOT ONE LINE OF IT CHANGED — I did not force, I did not rebase over it, and I dropped
every part of my own work that duplicated it.** Selftest **494/0/0** (their 490 + my 4).
The split turned out clean, which is luck rather than planning. `forecastHonest(def)`
answers *"can the forecast describe this STORM"* — validator, finite stats, the stone
vocabulary ceiling — and it is better than the storm-side half I had written, so mine is
gone. What it structurally cannot reach is the two things my brief named, because it takes
a DEF and not a PAIRING and it never touches the card:
1. **WHAT THE CARD PRINTS.** Their gate can pass a storm whose card then omits the fact
that matters. It does, tonight, on night 7 — see below.
2. **WHICH YARD the storm is printed over.** `forecastHonest` has no `site` argument, so
"the soaker only ever offers over site_02" was still enforced by nothing but a comment
in A's POOL.
My work is now exactly those two, added after a divider in weather.js that says so. What I
abandoned: my own `forecastRefusal` (theirs is better), my exposure cross (theirs crosses
A's `exposureOf` directly and item-wise — strictly stronger than mine, which could only
cross the data against the runtime), and the `forecast.js` pure-module split. **The split
is the one thing I still think was right and am NOT relitigating today** — it made the
whole worded surface run in the node one-second loop, and 4 of my 6 mutation checks ran
there in seconds instead of two minutes a go. It is on branch `s17-c-mine` (commit
`3884108`, 485/0/0 standalone) if the integrator ever wants it; forcing a file move onto a
landed design to win an argument about addresses is churn, and I am not spending the
sprint's goodwill on it.
[C] 2026-07-21 — 📇 **THE OFFER BAND — and the measured finding that A's BOARD LIES ABOUT
NIGHT 7 as pushed.** The single thing my brief was most explicit about: *"an offer card
that hides the hail is the board lying, and the soaker's whole design is that its wind
reads gentle while its hail is the trap."* It does, and it was.
**MEASURED FIRST, before a line was written.** A's card prints `f.wind` and `f.stones` — a
reasonable pair. Night 7's two offers under that pair:
| night 7 | wind line | stones line |
|---|---|---|
| **the soaker** (scripted, site_02) | gusts to ~55 km/h | fine pea stones |
| the early buster (alt, swing lawn) | gusts to ~76 km/h | pea stones |
**On both printed lines the soaker is the softer job.** It is not: 14.9 s of hail against
2.4 s, and 44 mm/hr of rain against 16.5. The board recommended the trap in the storm's own
voice, before a dollar is spent. Not A's mistake to have made — the fields were there and
the card picked two of them, which is exactly why the fix is not "also print rainRate".
**`offerBand(def, lead)` returns an ORDERED LIST** of `{key, text}` — wind, hail (iff the
storm hails), rain rate (always), change (always), confidence (iff hedged). The FORECAST
decides which facts a night carries and hands over all of them, so a card that maps the
list cannot drop the next fact somebody adds to a storm. Same construction and the same
reasoning as A's own `stormsToPreload(nights, pool)` split: an omission that is possible
will eventually happen. Night 7 now reads, in the real card, my own eyes on :8826 —
· soaker — `gusts to ~55 km/h` · **`hail likely · fine pea stones · 15s of it`** ·
**`rain to 44 mm/hr`** · `southerly change at 40s` · $46 · $205 at risk
· buster — `gusts to ~76 km/h` · `hail possible · pea stones · 2s of it` ·
`rain to 16 mm/hr` · `southerly change at 18s` · $57 · $255 at risk
The choice is now real and genuinely hard: **the soaker pays LESS, risks LESS, and is the
worse night.** That is the game arguing with the player instead of flattering them.
**⚠️ SECOND FINDING — THE CHANGE LINE IS NOT DECORATION.** `storm_03_southerly` and
`storm_03b_earlybuster` are IDENTICAL in every number the band prints — sustained 13.0,
gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4 s at size 0.70 — and differ ONLY in when the
change lands, 30 s against 18 s. **The board offers exactly that pair** (the buster is the
drawn alternative on nights 5 AND 7). Drop the change line and the board offers two jobs
whose weather it has just claimed to describe, described identically, and calls it a
choice. Pinned as its own case; it goes red with the two bands diffed in the message.
**A — THE HUNK, verbatim.** I applied it on a scratch tree to LOOK at the card, then
reverted it — hud.js and main.js are yours (the leadFor precedent).
1. `main.js` import: `import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';`
2. `main.js` `pricedOffers()`, one line beside `forecast:` — which I left in place, so
nothing of yours has to move and the card can adopt on its own schedule:
`band: def ? offerBand(def, 0) : null,`
3. `hud.js` `offerHtml`, replacing your two forecast lines with:
``${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}``
plus two CSS rules in your SPRINT17 board block:
`#hud-card .offer .line.wx { color:#8ba0ad; }` and
`#hud-card .offer .line.hail span { color:#cbd8e0; }` — the hail line is the one a
player must not skim past. `const f = o.forecast;` then goes unused in `offerHtml`.
**lead 0 and only lead 0, and I am not proposing a dial.** Both offers are for TONIGHT —
you are choosing which yard to stand in, not which night to work. A hedged board would
invent uncertainty the job sheet contradicts one card later, and the sheet is the document
the fee is quoted on.
[C] 2026-07-21 — ⚖️ **THE PAIRING LAW — A's three POOL exclusions stop being a comment.**
`forecastHonest` asks whether the forecast can describe a STORM. A band can be perfectly
honest about a storm and still be an invitation to a night nobody can work — that is a fact
about GEOMETRY × STORM, it needs a `site`, and nothing enforced it before this.
`PAIRING_LAW` (weather.js): storm key → `{sites, owner, why}`, with the measurement in the
data. **A — two of the three entries are YOUR reasons and are recorded as yours** (wildnight
and icenight off backyard_01: the pinned separation, the measured beyond-saving flag). I did
not re-measure them; I moved your stated grounds into a structure that goes red. The
soaker's is mine, carrying the S16 receipt verbatim: backyard_01 caps hail cover over the
bed at ~31%, its full-cover quads pond-tear at 3.33.7 kN on BOTH fabrics, best membrane
37.5 against cloth 17.3 — a fabric bet with no win in it. **An absent key means
UNRESTRICTED, deliberately:** this table records refusals somebody PAID FOR, and inventing
one for a pairing nobody flew is the same offence in the other direction. Pinned in both
directions, including `pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null`
— that is night 4, and a law that refused it would be refusing the shipped ladder.
`pairingRefusals(pairings)` is **deliberately NOT a filter.** Silently dropping a bad entry
leaves a morning with one offer and no explanation — a runtime surprise wearing a fix's
clothes. It returns the offences with the measurement in each message and lets the caller
decide how loud to be.
**RECEIPTS.** The check walks NIGHTS POOL carrying the SITE — the same union the other
session's storm walk uses, extended by the dimension that walk cannot see (their own reason
is the right one: today every POOL storm is also in NIGHTS, so a NIGHTS-only walk is green
by coincidence). **12 pairings, 0 refusals.** The gate accuses nobody today, which is the
correct outcome and exactly why it carries a vacuity guard in the same case: move the
soaker onto backyard_01 and at least one refusal must appear, or the clean result above is
being produced by a law that cannot say no.
**A — if you want it at DATA level rather than in my suite**, `import { pairingRefusals }
from './weather.js'` in `board.js` and throw on a non-empty result at load. My brief's words
were "a DATA-level constraint on the pool agreed with A in THREADS — never a runtime
surprise", and the suite is where I can put it without touching your file. Yours to promote.
[C] 2026-07-21 — 🔬 **MUTATIONS, THE DISCLOSURE, AND A FALSE ALARM I KILLED BY MEASURING IT.**
Six run on my own tree before the convergence, and the two that guard the surviving code
re-run on THIS tree after the code changed address (the whole point of re-running: a
mutation receipt is about the shipped file, not about a file that used to exist):
| mutation | result |
|---|---|
| offer band drops the HAIL line (the night-7 trap) | RED — "storm_02_wildnight: hails for 11.4s but the band's hail line is MISSING" |
| soaker's restriction deleted from `PAIRING_LAW` | RED ×2 — "the soaker over the backyard is offerable — the ~31% hail-cover measurement has stopped being enforced" |
| offer band drops the CHANGE line | RED — "the two busters differ on `sustained to 13 m/s…` — expected the CHANGE TIME to be the difference" |
| offer band drops the rain-rate line | RED — "storm_01_gentle: no rain-rate line on the offer band" |
| night 7's soaker moved onto backyard_01 in week.js | RED — the ladder gate names the yard AND the measurement; D's two week laws also went red, which is them working |
| `collateralKey: "swing_set"` stripped from site_03 JSON | RED — reachability; superseded by the other session's stronger cross, recorded because it was run |
All restored; final run green.
**⚠️ A HOLE IN MY OWN GATE, found by probing it rather than reading it** — and worth
recording even though the code it lived in is gone, because the OTHER session's
`STONE_WORD_CEILING` fixes the top of exactly this range and nothing yet fixes the bottom:
a def with `hail.size: 0` and a live hail curve worded itself **"fine pea stones"**, because
`stoneWord` has no FLOOR and `0 < 0.6`. The card names a stone the storm does not carry, on
the axis the entire fabric bet is decided on. Their ceiling refuses 2.6; nothing refuses 0.
**Filed for whoever holds the pen on `forecastHonest` next** (one line beside the ceiling
check): `if (s.hailSeconds > 0 && !(s.hailSize > 0)) errors.push(…)`. Not landed by me — it
is inside their predicate and this is the wrong sprint for two people editing one function.
**THE FALSE ALARM, recorded because the process is the point.** I thought my dressed worlds
had taken the selftest from ~25 s to ~115 s and was about to trim the harness. Measured it
instead: **baseline 485/0/0 takes 131 s; 496/0/0 with my work took 115 s.** The "25 s" was
never a measurement — it was the first number I happened to read off a poll. Dressing all
three sites costs ~90 ms total (loadSite 45 ms, dress 2832 ms each). Nothing was trimmed,
and the suspicion is dead rather than parked. Measure, don't reason.
**Two smalls for A, neither urgent.** (1) Pool ids `corner_block_no_house` and
`corner_block_cheapest` — the first is `site_03_swing_lawn`, not the corner block (the
`_why`, the client and the brief are all right; only the id reads wrong, and ids surface in
`takenOffer` and the settlement). (2) On most nights the two offers carry DIFFERENT STORMS
for the same night — a fiction question, not a bug. Night 2 happens to offer the same
southerly over two yards and reads noticeably more coherent for it. Yours to rule if it
ever matters; the band tells the truth either way.
[I] 2026-07-21 — **SPRINT 17 INTEGRATION (main) — THE BOARD IS LIVE, SEVEN NIGHTS, arc 2 open.**
Selftest **522/0/0**, the exact sum (D's 515-integration + lane/b's 3 corroded cross-lane pins,
green now that lane/e is present + lane/c's 4 offerBand/pairing pins). Verified in the browser
and deployed to partly.party/hardyards. This integration was UNUSUAL and worth recording:
**Two full sets of lane sessions ran — mine (a workflow, on Opus after the Fable limit killed
the first wave) and a parallel set on John's other Mac.** Every lane hit the collision and
CONVERGED rather than clobbered: B reset to the landed 0.2/0.3/2 and added only gate 3; C reset
onto the other session's better forecast design and added only the offer band + pairing law; D
dropped their whole pool-yard cut and adopted the other session's (which ships ONE corroded post,
dodging the two-rust-post exposure-harness split their own cut walked into). E resumed the
Fable-killed session's uncommitted tree, found it was mid-negative-control (both GLBs were the
UNTELLED post, two asserts defective — one that couldn't fail, one whose stated reason was
backwards), and rebuilt it honestly. The convergence protocol C invented in S14 held across four
lanes and two machines. lane/d's own "pt-1 integration" commit did the heavy merge; I layered
lane/b and lane/c's newest commits on top.
**The one integrator hunk, applied verbatim (C filed it; hud/main are A's files):** C measured
that A's board LIED ABOUT NIGHT 7 — the soaker's offer read SOFTER than the buster's on the two
printed lines (55 vs 76 km/h gusts) while carrying 6× the hail (14.9 s vs 2.4) and 2.7× the rain.
The board was recommending its own trap in the storm's own voice. `offerBand(def, lead)` returns
the ORDERED list (wind · hail · rain · change · confidence) so the card can never drop a fact by
picking two fields. Applied the main.js `band:` line + the hud.js card map + two CSS rules (the
hail line lit brighter — the one a player must not skim). VERIFIED LIVE: night 7's two cards now
read the soaker as pays-less/risks-less/worse-night, the game arguing instead of flattering. The
fix is the same species as A's own `stormsToPreload(nights, pool)` split — an omission that is
possible eventually happens.
**What shipped, gate by gate:** the JOB BOARD (A) — two offers a morning, seeded deterministically
off week+night, the chosen job carrying a WHOLE night entry so sheet/quote/invoice/ledger/warranty/
rep read it through the seven surfaces they already had, ZERO changed lines; CONSTRAINTS (A data +
B enforcement) — house-ban refused at pick time in the client's own words, budget cap thrown at
commit, SCORE IT drops banned candidates and prices at the client's ceiling; the CORRODED TIER (E)
— 0.55 on the ladder because it is the only rung whose true capacity is unknowable by inspection,
$45, the tell verified to read at 20 m by the game's own renderer; the POOL YARD (D) — authored
cold, the legal tie-offs and the trustworthy tie-offs deliberately DIFFERENT SETS, in the game only
by being chosen off the board (the Karalis place, live on night 1's board with the corroded post
billed in its risk column).
**Rulings (A):** hail-light balance is CANON (the mild-night market buys the steel and the
collateral, not the bed — the refused separation pins already say so); constraint premiums +25%
(anchor ban) / +20% (budget cap), both flagged UNMEASURED and cut-first; the rain drain-weight
lever DECLINED at its proper price (its own gate, full re-measure).
**Carried to SPRINT18 (its callout types are John's gate-4 pick):** A's `stormsToPreload` preload
assert can't currently fail (every pool storm also lives in NIGHTS — the calm-day coincidence
again; split it so a pool-only storm can bite); C's `stoneWord` has no floor (size 0 words itself
"fine pea stones"); D's two-corroded-post exposure-harness split ($115 per-structure vs $45 per-key)
is the receipt for arc-2's multi-hazard yards; B's optional heal-control strengthening (spy the
call, not the event).
**Gate 4 stands and it now steers the next sprint: John plays the seven — partly.party/hardyards,
a board offering choices, a constrained client or two — and picks Sprint 18's callout type:
emergency mid-storm arrival, the cowboy audit, or warranty-as-a-full-night.**

View File

@ -906,6 +906,53 @@
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post_corroded",
"dims": [
0.507,
0.52,
4.0516
],
"tris": 816,
"nodes": [
"footing",
"pad_eye",
"post",
"rake_pivot",
"rust",
"sail_post_corroded",
"top_anchor"
],
"anchors": [
{
"node": "top_anchor",
"anchor_type": "corroded_post",
"rating_hint": 0.55,
"collateral": "corroded_post"
}
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post_corroded_wrecked",
"dims": [
0.7786,
4.0784,
0.7015
],
"tris": 836,
"nodes": [
"footing",
"pad_eye_down",
"post_down",
"post_stub",
"sail_post_corroded_wrecked"
],
"anchors": [],
"status": "PASS",
"problems": []
}
],
"debris": []

View File

@ -137,6 +137,14 @@ PAL = {
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
"bike_grip": "#3B4048", # grips and saddle
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
"steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft.
# Next to steel_gal (#B6BCC2) it reads DIRTY at
# 20 m before you can see a single bloom — the
# colour is the long-range half of the tell.
"rust_bloom": "#9C5B33", # active surface rust — orange enough to read
# against every steel/timber in the palette
"rust_deep": "#67351F", # the worst of it: fold lines, weld collars,
# streak stains — near-brown, high saturation
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
# a pool is the one water in the game that is
@ -2256,6 +2264,284 @@ def build_sail_post(name):
return root
# ---------------------------------------------------------------------------
# THE CORRODED TIER (SPRINT17 gate 3.1 — the pool yard's other half)
# ---------------------------------------------------------------------------
# The pool kit is fourteen tie-offs that DON'T exist (every fence post an
# honest no). This is the tie-off that DOES exist and shouldn't be trusted:
# a real sail post, real concrete footing, real pad eye — gone rusty.
#
# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post <
# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post):
# · ABOVE the swing frame (0.45): the footing is still 300 mm of concrete
# and the load path still reaches the ground. A sound welded frame
# standing loose on grass drags; a rusty post in a real footing holds
# until its steel gives — corroded anchored beats sound unanchored.
# · BELOW the pergola (0.65): the pergola's members are SOUND and its
# weakness (deck flex) is visible and bounded. Corrosion's weakness is
# section loss you cannot see the bottom of — the bloom on the outside is
# pitting on the inside, so the visible rust is a FLOOR on the damage,
# not a ceiling. You de-rate below sound timber because you're rating
# what you can't inspect.
# · And it FILLS the 0.45→0.65 gap: rungs create decisions (D's S15
# endorsement logic, the reason the pergola exists). With this rung the
# ladder steps 0.45 / 0.55 / 0.65 — three different kinds of "not quite":
# unanchored steel, eaten steel, flexing timber.
CORRODED_POST_RATING = 0.55
# The bill when it snaps. Proposal — Lane A confirms (my S17 prompt says rule
# it and let A confirm): a post IS the client's property, so a snapped one
# bills — "it was already scrap" is a warranty argument, not a free pass. But
# the steel was condemned before you touched it, so what you're billing is
# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out
# of the footing. That's under the gutter's $90 (a full trade's morning run)
# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting
# corroded steel is the corner you lose mid-storm — the trap costs you in
# sail HP and garden exposure, and the invoice line is honest small change
# beside it. If the bill were big, the lesson would read "don't touch rust";
# at $45 it reads "rust is a bet, and here's the stake".
CORRODED_POST_COLLATERAL = 45
def build_sail_post_corroded(name):
"""The corroded sail-post variant — same bones as build_sail_post (H, R,
footing, rake_pivot mechanism all identical, so it rakes and places like
any post), different truth.
THE TELL, designed to read at two ranges (the bike lesson: LOOK at it,
and look from where the player actually stands):
· 20 m / colour: the whole shaft is weathered matte (steel_weathered)
against the honest post's bright galvanised, the footing collar wears
a rust stain skirt, and the cap sits askew. You don't see rust from
the back fence you see a DIRTY post next to clean ones.
· 3 m / detail: rust blooms where posts actually rust (the base band
where water pools on the collar, the head collar around the pad-eye
weld), deep-rust fold line just above the footing, streaks bleeding
down the shaft, and the pad eye SAGS the weld ate through and the
eye drooped toward the yard. The anchor empty sits at the DROOPED
eye, so the sag is a measured fact, not set dressing: e.test derives
the droop ANGLE back out of the exported anchor position and pins it
to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower
than the honest one" would NOT have worked — the eye hangs off a weld
line 160 mm down the post, so that test reads 0.06 m and passes at
zero droop. Measure the angle, not the altitude.)
Corrosion a player can't see is a trap with no tell, and this repo
doesn't ship those — the three tells are pinned as three SEPARATE tests
in e.test.js (rust area, shaft lightness, eye droop) so that one failure
can't mask the other two. Rebuild this in clean steel at the same 0.55
hint and all three go red; flatten only the eye and only the third does.
"""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048 # same post the honest one is — that's the trap
DROOP_DEG = 38.0 # pad-eye sag off vertical. 38° is slumped
# past argument but still recognisably an eye
# you COULD clip to — which is the whole trap.
DROOP = math.radians(DROOP_DEG)
EYE_ARM = 0.08 # eye centre's distance from the weld line
WELD_Z = H - 0.16 # the slumped weld line the eye hinges at
# Footing identical to the honest post's, plus the stain skirt: rust
# bleeds onto the collar top, so even the GROUND LINE reads brown.
join_group([
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# Same rake mechanism as build_sail_post — rake is a runtime decision and
# a corroded post rakes like any other; the lean you might be tempted to
# bake here would double-apply the moment a player rakes it.
rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)"
rake["rake_default_deg"] = 8
above = []
above.append(join_group([
add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
# The cap sits askew — rust let the friction fit go. Silhouette tell.
add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12,
rot=(math.radians(14), 0, 0)),
], "post"))
# The corrosion itself, its own node so the mutation control can kill it
# cleanly: blooms slightly proud of the shaft (rust swells), streaks
# bleeding DOWN from each bloom. Positions are hand-placed, not rng — the
# blooms sit where posts actually rust, and determinism costs nothing.
corrosion = [
add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12),
add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12),
add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12),
add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12),
]
for i, (ang_deg, length, z_top) in enumerate([
(200, 1.45, H - 0.24), # off the weld collar, yard side (-y)
(335, 0.95, H - 0.30),
(25, 0.70, H - 0.35),
(160, 0.80, 0.95), # wicking up off the base band
(300, 0.60, 0.85)]):
a = math.radians(ang_deg)
corrosion.append(add_box(
f"{name}_streak_{i}", (0.016, 0.016, length),
(math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003),
z_top - length / 2), rust_d, rot=(0, 0, a)))
above.append(join_group(corrosion, "rust"))
# The sagging pad eye: plate and ring both pitched DROOP about the weld
# line, hanging toward -y (three.js +Z, the yard side — same face the
# pergola presents). Built in the tilted plane directly, no post-rotation.
ring_c = (0, -math.sin(DROOP) * EYE_ARM,
WELD_Z + math.cos(DROOP) * EYE_ARM)
eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
(0, -math.sin(DROOP) * EYE_ARM / 2,
WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d,
rot=(-DROOP, 0, 0))]
r_eye, segs = 0.026, 10
pts = []
for i in range(segs + 1):
t = math.tau * i / segs
pts.append((math.cos(t) * r_eye,
ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP),
ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP)))
for i in range(segs):
eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1],
0.008, rust, verts=8))
above.append(join_group(eye_parts, "pad_eye"))
# The anchor sits AT the drooped eye — the sag is where you'd clip on,
# so the sag is data the sim sees, not paint.
e = add_empty("top_anchor", ring_c, size=0.2)
e["anchor_type"] = "corroded_post"
e["rating_hint"] = CORRODED_POST_RATING
e["collateral"] = "corroded_post"
e["why"] = ("a real post in a real footing, eaten: above the swing frame "
"(0.45) because concrete beats loose-on-grass, below the "
"pergola (0.65) because the bloom outside is pitting inside — "
"you de-rate what you can't inspect. The rung fills 0.45→0.65; "
"rungs create decisions")
above.append(e)
for o in above:
parent_keep_transform(o, rake)
stamp(root, name, "structure")
root["post_height"] = H
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
root["breakable"] = True
root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust "
"area, shaft dullness and the eye droop — a "
"clean-steel rebuild at the same hint goes red")
# THE AXIS TRAP (E's swing_set precedent, fourth application). The droop
# is an ORIENTATION CLAIM, and orientation claims in this repo are the
# ones that rot: the exporter maps Blender (x,y,z) → three.js (x,z,y), so
# a docstring saying "-y" and a mesh leaning +Z agree only by luck. These
# extras are stated in THREE.JS coords and e.test recomputes them off the
# exported geometry — so note and mesh can only ever lie together.
# Blender ring_c (0, sinD·ARM, WELD_Z+cosD·ARM)
# → three.js (0, WELD_Z+cosD·ARM, +sinD·ARM)
# i.e. the eye hangs toward +Z, the yard side, the face the pergola
# presents — so the sag is pointed at the player who has to judge it.
root["padeye_droop_deg"] = DROOP_DEG
root["padeye_arm_m"] = EYE_ARM
root["padeye_droop_dir_threejs"] = "+Z"
root["padeye_weld_y_threejs"] = WELD_Z # blender z → three.js y
root["padeye_droop_note"] = (
"top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off "
"vertical toward three.js +Z (the yard). e.test derives the angle "
"back out of the exported anchor position and pins it to this number: "
"flatten the eye and the note goes red with it")
return root
def build_sail_post_corroded_wrecked(name):
"""The corroded post after a loaded corner won: FOLDED at the deep-rust
line just above the footing (where the intact variant wears its worst
band the wreck fails exactly where the tell said it would), stub
standing plumb with a torn rim, the rest of the post flat on the grass
reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still
drooped at the far end with nothing left to hold. Same footing, same
numbers, same price as its twin (the carport/gutter chain, application
six). No anchor empty survives an anchor that outlives its structure
is the free-failure bug in a costume (fascia rule, fourth application)."""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048
FOLD = 0.46 # it let go at the rust line, not the weld:
# the fold is where the section was thinnest
join_group([
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# The stub: plumb (the footing held — that half of the 0.55 was honest),
# deep rust at the fold, torn rim leaning the way the post went.
join_group([
add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
add_cyl(f"{name}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07),
rust_d, verts=12),
add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09,
(0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10,
rot=(math.radians(18), 0, 0)),
], "post_stub", root)
# The fallen length, resting on its radius, slightly yawed — nothing real
# falls on axis. Blooms ride along it where the intact bands were.
L = H - FOLD
yaw = math.radians(7)
rotv = (math.radians(90), 0, yaw)
def along(s): # a point s metres from the break end, down the fallen shaft
return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R)
down = [
add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv),
add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d,
verts=12, rot=rotv),
add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark,
verts=12, rot=rotv),
]
join_group(down, "post_down", root)
# The pad eye at the far end, on the grass, still drooped, still rusty —
# the one part of this post that was never going to hold anything again.
ex, ey, _ = along(L - 0.10)
eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07),
(ex + 0.09, ey, 0.035), rust_d,
rot=(0, math.radians(80), 0)),
add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust,
segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')]
join_group(eye, "pad_eye_down", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "sail_post_corroded"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
return root
def build_ladder_01(name):
root = add_empty(name)
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
@ -3544,6 +3830,19 @@ ASSETS = [
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
"fence_post_01", "fence_post_14"]),
# SPRINT17 gate 3.1 — the corroded tier. The intact variant must stand
# POST height (its lie is "I'm a sail post", so it stands like one; only
# the colour and the drooped eye say otherwise). The wreck's z ceiling is
# under a metre because the post FOLDED at the rust line — a corroded
# wreck still standing tall is a wreck that never told the truth — and
# its y floor is past 3.5 because the fallen shaft is out in the yard.
dict(name="sail_post_corroded", fn=build_sail_post_corroded,
dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)),
nodes=["footing", "post", "rust", "pad_eye", "top_anchor",
"rake_pivot"]),
dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked,
dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)),
nodes=["footing", "post_stub", "post_down", "pad_eye_down"]),
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@ -99,7 +99,7 @@ export function hardwareByName(name) {
* 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, porosity = 0.30 }) {
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = AUDIT.POROSITY }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
@ -107,8 +107,8 @@ 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) unless the caller is measuring the fabric
// bet — same default as sweep.js, so nothing pre-SPRINT16 moves.
// shade cloth (AUDIT.POROSITY — the sweep charter, one knob since
// SPRINT17 gate 0.3) unless the caller is measuring the fabric bet.
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.

View File

@ -33,6 +33,15 @@ import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { FIXED_DT } from '../../web/world/js/contracts.js';
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
import { buildScoringWorld, collateralExposure } from './scorecard.js';
import { factoryExtras } from '../../web/world/js/ratings.js';
// SPRINT17 gate 3 [B] — CROSS-LANE SEAM. The corroded tier (rating_hint 0.55,
// the GLB, the gen entry) lives on lane/e. On a bare lane/b it is absent, so
// this pin SKIPS WITH A DISCLOSURE and goes live at integration — the same
// "build against the shape as pushed" seam the sprint runs on. Measured GREEN
// on the scratch merge (lane/a + lane/e): hint 0.55, collateral $45.
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -119,7 +128,12 @@ export async function buildGardenflyTests() {
let sepStates = null, sepStatesErr = null;
try {
sepStates = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
// SPRINT17 gate 3.2 [D]: site_04_pool_yard added to the judged walk in the
// same commit that ships it (B's file, flagged in THREADS, revert-and-tell-
// me standard) — a yard missing from this list is UNJUDGED silently, which
// is the exact silence the gate-2.3 rule forbids. It carries a refusal
// finding (bare 83.7 FULL under its southerly; receipts in the site file).
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', 'site_04_pool_yard']) {
const sd = await loadSite(name);
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
}
@ -151,7 +165,62 @@ export async function buildGardenflyTests() {
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
} catch (err) { fabErr = String((err && err.stack) || err); }
// SPRINT17 gate 3 support [B]: the corroded tier priced through the CARD'S
// OWN path, in a real dressed world. E landed the post; the 0.2/0.3/2 pass
// deferred gate 3; this pins that a corroded anchor prices honestly the
// moment it is placed. Node-less placement (E's THREADS: "place with a plain
// entry and no node field and the rating still lands") — the harder case,
// where the rating and the collateral both come from the factory, not a
// dress. buildScoringWorld only DRESSES (no sweep), so this is ~40 ms.
let corRun = null, corErr = null;
try {
if (!CORRODED_TIER_LIVE) throw { skip: true };
const corSite = await loadSite('backyard_01');
corSite.structures = [...(corSite.structures ?? []), {
id: 'cor1', model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1',
x: 6, z: 2, rotYDeg: 0, solid: true,
collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post',
anchors: [{ id: 'cor1_a1', type: 'corroded_post', work: 'cloth' }],
}];
const built = await buildScoringWorld(corSite);
const anc = built.anchors.find((a) => a.id === 'cor1_a1');
const col = {};
for (const a of built.anchors) {
if (!a.collateral) continue;
const p = built.world.collateralFor(a.collateral);
col[a.id] = p ? { key: a.collateral, cost: p.cost, label: p.label }
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
corRun = { anchor: anc, priced: built.world.collateralFor('corroded_post'),
exposure: collateralExposure({ collateral: col }), dressed: built.dressed, dressError: built.dressError };
} catch (err) { if (err && err.skip) corErr = 'skip'; else corErr = String((err && err.stack) || err); }
return [
['gate 3: a corroded post prices honestly in the card — 0.55 hint, $45 collateral, dressed', () => {
if (!CORRODED_TIER_LIVE) return 'SKIPPED — corroded tier lands with lane/e; measured GREEN on '
+ 'the scratch merge (hint 0.55, collateral $45 through collateralFor)';
if (corErr) throw new Error(`corroded pricing died: ${corErr}`);
assert(corRun.dressed, `the corroded world did not dress (${corRun.dressError}) — GLB missing or broke`);
const a = corRun.anchor;
assert(a, 'the corroded anchor cor1_a1 was not created');
// The STEEL half, node-less: factoryExtras had to reach it, or the tier
// math prices this trap as the best steel in the game (adoptAnchor's
// `?? 1`, E's load-bearing _v1 warning).
assert(a.ratingHint === 0.55, `corroded anchor rated ${a.ratingHint}, not 0.55 — a node-less placement `
+ 'lost E\'s factory hint and the card would price it as sound steel');
assert(a.collateral === 'corroded_post', `corroded anchor collateral "${a.collateral}", not "corroded_post"`);
// The COLLATERAL half, through the card's OWN resolver and exposure sum —
// not the baked GLB value read directly (the carport-to-null shortcut).
assert(corRun.priced && corRun.priced.cost === 45,
`world.collateralFor('corroded_post') gave ${JSON.stringify(corRun.priced)}, expected $45`);
assert(corRun.priced.label === 'the corroded post', `collateral label "${corRun.priced.label}"`);
const line = corRun.exposure.priced.find((e) => e.key === 'corroded_post');
assert(line && line.cost === 45,
`collateralExposure has no $45 corroded line (${JSON.stringify(corRun.exposure.priced.map((e) => [e.key, e.cost]))})`);
assert(!corRun.exposure.unpriced.some((e) => e.key === 'corroded_post'),
'the corroded post landed in UNPRICED — the exposure total silently drops its $45');
return `corroded_post: hint ${a.ratingHint}, collateral $${corRun.priced.cost} "${corRun.priced.label}", in the exposure sum`;
}],
['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):
// the pinned recipe HOLDS and rigging matters by a full state's width —
@ -204,7 +273,7 @@ export async function buildGardenflyTests() {
// 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}`);
assert(sepStates.length === 4, `expected 4 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; ` +

View File

@ -53,9 +53,46 @@
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld } from '../../web/world/js/world.js';
import { FABRIC } from '../../web/world/js/rigging.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
/**
* SPRINT17 gate 0.3 the score's fabric charter, SAID OUT LOUD. [B, with D's
* soaker finding as the brief and A consulted on the card wording.]
*
* Every audit number is flown on knitted shade cloth (AUDIT.POROSITY one
* knob, sweep.js and gardenfly.js both read it). Sound charter, but the card
* never NAMED it, and on the one night where fabric is the whole answer the
* silence lied by omission: the soaker's card said "✓ WINNABLE at $80" over a
* garden table where every cloth line reads DEAD, and a reader without the
* charter in their head concludes the night is broken. The fix is the same
* discipline as the funnel state: any front-end printing a score also prints
* what fabric it flew and on a storm whose stones pass the weave (C's 2 mm
* ruling, the same hailBlockFor the sim charges), it says the F key is not in
* the card.
*
* Pure data from pure inputs, browser-free, so the selftest can pin it against
* real storm defs without a flight.
*
* @param {object|null} stormDef parsed storm def (hail.size in storm units)
* @returns {{ fabric: {id,name,porosity}, leaks: boolean }}
* `fabric` the charter entry (matched out of rigging's FABRIC by the
* porosity actually flown, so a charter retune renames the card by itself);
* `leaks` true when tonight's stones pass this weave, i.e. the un-swept
* membrane would stop stones the swept cloth lets through.
*/
export function fabricCharter(stormDef) {
const f = FABRIC.find((x) => x.porosity === AUDIT.POROSITY)
?? { id: 'custom', name: `porosity ${AUDIT.POROSITY}`, porosity: AUDIT.POROSITY };
const hailSize = stormDef?.hail?.size ?? 0;
return {
fabric: { id: f.id, name: f.name, porosity: f.porosity },
leaks: hailSize > 0 && hailBlockFor(hailSize, AUDIT.POROSITY) < 1,
};
}
/** How many affordable lines get flown. Flights are seconds each; the card is
* on-demand and slow is fine, but an unbounded sweep on a yard with fifteen
* anchors is a hang, not a score. */
@ -129,7 +166,7 @@ export async function buildScoringWorld(site) {
* @returns {Promise<object>} pure data no DOM, no strings-as-verdicts
*/
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
onProgress = null, yieldEvery = 1 }) {
onProgress = null, yieldEvery = 1, constraints = [] }) {
const built = prebuilt ?? await buildScoringWorld(site);
const { world, anchors, bed, use, dressed, dressError } = built;
@ -164,8 +201,11 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
: { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
}
const { cands, rows, verdict, winners, marginalWinners } =
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery });
// gate 2 (SPRINT17): a constrained NIGHT scores under its client's terms —
// bans shrink the candidate list before a single flight, the cap decides
// affordability. A card that recommends a forbidden line is the card lying.
const { cands, rows, verdict, winners, marginalWinners, budget, constrainedOut } =
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery, constraints });
// Garden flights: the bare-bed control first, then every line the budget can
// buy. The flight list is known up front so the progress tick has an honest
@ -232,10 +272,19 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
}
}
// The charter the whole card is denominated in (gate 0.3): reported next to
// the funnel state, computed from the storm actually scored — never assumed.
const charter = fabricCharter(stormDef);
return {
site: site.id ?? site.name ?? '(unnamed)',
storm: stormName, dressed, dressError,
venturi, funnelOn: venturi.length > 0,
fabric: charter.fabric, fabricLeaks: charter.leaks,
// gate 2: the terms this card was scored under, echoed so every front-end
// can print them — a constrained score that doesn't SAY so is the soaker
// silence again, one gate over. `budget` is what decided affordable/clean.
constraints, budget, constrainedOut,
anchorCount: anchors.length, bed,
cands: cands.length, rows, winners, marginalWinners, verdict,
flown, skipped, flyCap,

View File

@ -109,8 +109,10 @@ import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.j
// correction, and the right call: a pin that retypes the thing it pins agrees
// with itself by construction.
import { createWindRouter } from '../../web/world/js/main.js';
import { buildScoringWorld } from './scorecard.js';
import { auditSweep, auditSweepAsync } from './sweep.js';
import { buildScoringWorld, fabricCharter } from './scorecard.js';
import { auditSweep, auditSweepAsync, AUDIT } from './sweep.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { START_BUDGET } from '../../web/world/js/contracts.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -408,5 +410,137 @@ export async function buildScorecardTests() {
`gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`);
}]);
// ── SPRINT17 gate 0.3: the fabric charter is named, and named RIGHT ──────
// D's soaker finding: the card said WINNABLE over an all-DEAD cloth garden
// and nothing named the charter. These pin the disclosure's DATA against
// real storm defs — the soaker (fine stones, the one night the charter is
// the wrong bet) must read leaks:true, the icenight (1.4 fists of ice that
// no weave passes) must read leaks:false, and a dry storm must never warn.
// The wording is the card's; the truth of the flag is pinned here.
{
const soaker = await loadStorm('storm_06_soaker');
const icenight = await loadStorm('storm_02b_icenight');
tests.push(['gate 0.3: the charter names a real fabric, and it is the cloth the sweep flies', () => {
const c = fabricCharter(soaker);
assert(c.fabric.id === 'cloth' && c.fabric.porosity === AUDIT.POROSITY,
`gate 0.3: the charter resolved to '${c.fabric.id}' porosity ${c.fabric.porosity} — the card `
+ `would name a fabric the sweep does not fly (AUDIT.POROSITY ${AUDIT.POROSITY}). If the `
+ 'charter knob moved, rigging.js FABRIC must carry an entry at the new porosity or the '
+ 'card degrades to a raw number.');
assert(hailBlockFor(soaker.hail.size, AUDIT.POROSITY) < 1,
'gate 0.3 vacuity guard: the soaker\'s stones no longer pass the charter weave — the '
+ 'leaks pin below would be testing a night that cannot leak. Re-pick the fixture storm.');
}]);
tests.push(['gate 0.3: soaker leaks (fine stones pass the weave), icenight does not, dry night silent', () => {
assert(fabricCharter(soaker).leaks === true,
`gate 0.3: the soaker (hail.size ${soaker.hail.size}) reads leaks:false — the one card `
+ 'the disclosure exists for would not carry it. hailBlockFor and the charter porosity '
+ 'no longer agree with C\'s weave ruling.');
assert(fabricCharter(icenight).leaks === false,
`gate 0.3: the icenight (hail.size ${icenight.hail.size}) reads leaks:true — stones an `
+ 'order of magnitude over the weave aperture do not pass it, and a warning on every '
+ 'card teaches the player to ignore the one that matters.');
assert(fabricCharter({ hail: null }).leaks === false && fabricCharter(null).leaks === false,
'gate 0.3: a storm with no hail (or no storm at all) warns about a leak — the flag must '
+ 'mean "tonight\'s stones pass the weave", never "the weave has holes in principle".');
}]);
}
// ── SPRINT17 gate 2: the audit honours the night's constraints ───────────
// A card that recommends a forbidden line is the card lying — so the sweep
// itself refuses banned steel and prices against the client's cap, and these
// pin it on a fixture where both bites are provably non-vacuous. Shapes are
// A's seam contract verbatim; enforcement asserts for the SESSION live in
// rigging.selftest.js — this is the SCORE IT half of the same gate.
{
// YARD5 with the fifth anchor as HOUSE steel — same geometry, one family
// change, so every delta below is the constraint and nothing else.
const YARD5H = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
{ id: 'a5', type: 'house', pos: { x: 0, y: 3.9, z: 4 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const HOUSE_BAN = { kind: 'noAnchorFamily', family: 'house',
label: 'nothing attached to the house',
says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.' };
const sweepArgs = { anchors: YARD5H, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] };
const baseH = auditSweep({ ...sweepArgs });
const emptyCon = auditSweep({ ...sweepArgs, constraints: [] });
const bannedH = auditSweep({ ...sweepArgs, constraints: [HOUSE_BAN] });
tests.push(['gate 2: constraints:[] is byte-identical to no constraints at all', () => {
assert(sweepJSON(emptyCon) === sweepJSON(baseH),
'gate 2: an empty constraint list changed the sweep — the unconstrained path is not '
+ 'the default path, and every historical number is quietly a different claim.');
assert(emptyCon.budget === START_BUDGET && emptyCon.constrainedOut === 0,
'gate 2: an unconstrained sweep reports a cap or a drop that did not happen');
}]);
tests.push(['gate 2: the house ban removes every line touching house steel — and provably removed something', () => {
assert(baseH.cands.some((c) => c.ids.includes('a5')),
'gate 2 vacuity guard: no unconstrained candidate touches a5 — the ban below would be '
+ 'measuring nothing. Fix the fixture, not the assert.');
assert(bannedH.constrainedOut > 0, 'gate 2: the ban dropped nothing on a yard with house candidates');
assert(bannedH.cands.length + bannedH.constrainedOut === baseH.cands.length,
'gate 2: dropped + kept != total — the filter is inventing or eating candidates');
for (const c of bannedH.cands) {
assert(!c.ids.includes('a5'),
`gate 2: candidate ${c.ids.join(',')} survived the house ban with a5 in it — `
+ 'the card would recommend a line the session refuses to rig, i.e. the card lying');
}
for (const r of [...bannedH.winners, ...bannedH.marginalWinners]) {
assert(!r.ids.includes('a5'), `gate 2: recommended line ${r.ids.join(',')} touches banned steel`);
}
}]);
tests.push(['gate 2: the cap re-prices affordability without touching a single load', () => {
// The cap is derived from the fixture's own price list so the test
// tracks the physics instead of hardcoding tonight's dollars — and so
// it can never go vacuous: when the yard's clean lines differ in price
// the cap sits at the cheapest (keeping it, excluding the rest); when
// they all cost the same (this symmetric fixture's actual shape, the
// first run of this test proved it) the cap sits $1 under, and must
// exclude every clean line.
assert(baseH.winners.length >= 1, 'gate 2 vacuity guard: no clean line to cap — re-fixture');
const prices = [...new Set(baseH.rows.filter((r) => r.cleanHw != null).map((r) => r.cleanHw))]
.sort((a, b) => a - b);
const capVal = prices.length >= 2 ? prices[0] : prices[0] - 1;
const over = baseH.rows.filter((r) => r.cleanHw != null && r.cleanHw > capVal);
assert(over.length >= 1, 'gate 2 vacuity guard: the derived cap excludes nothing — unreachable by construction, so the derivation regressed');
const capped = auditSweep({ ...sweepArgs, constraints: [{ kind: 'budgetCap', cap: capVal,
label: `client caps the rig at $${capVal}`, says: 'Cheapest option that does the job.' }] });
assert(capped.budget === capVal, `gate 2: cap $${capVal} not echoed (got ${capped.budget})`);
assert(capped.winners.length === baseH.winners.length - over.filter((o) => baseH.winners.some((w) => w.ids.join(',') === o.ids.join(','))).length,
`gate 2: ${capped.winners.length} winner(s) under the cap — the cap must remove exactly the clean lines it cannot buy`);
for (const r of capped.winners) {
assert(r.cleanHw <= capVal, `gate 2: winner ${r.ids.join(',')} at $${r.cleanHw} sold over a $${capVal} cap`);
}
for (const o of over) {
const twin = capped.rows.find((r) => r.ids.join(',') === o.ids.join(','));
assert(twin && !twin.clean,
`gate 2: ${o.ids.join(',')} ($${o.cleanHw} clean) still reads clean under a $${capVal} cap`);
assert(twin.cleanHw === o.cleanHw && twin.hw === o.hw,
`gate 2: the cap moved a PRICE on ${o.ids.join(',')} — the client caps the invoice, `
+ 'never the physics; loads and tiers must be byte-identical');
}
return `cap $${capVal} (${prices.length >= 2 ? 'cheapest of ' + prices.length + ' prices' : 'all clean lines priced $' + prices[0] + ', capped under'}) — ${over.length} line(s) priced out, loads untouched`;
}]);
tests.push(['gate 2: a ban that empties the yard says no-cover honestly, with the drop counted', () => {
const allBanned = auditSweep({ ...sweepArgs, constraints: [{ kind: 'noAnchorFamily', family: 'post',
label: 'nothing on the posts', says: 'Those posts are heritage-listed, believe it or not.' }] });
assert(allBanned.cands.length === 0 && allBanned.verdict.code === 'no-cover',
'gate 2: banning every rideable family did not empty the sweep');
assert(allBanned.constrainedOut === baseH.cands.length,
`gate 2: emptied yard reports ${allBanned.constrainedOut} dropped, expected ${baseH.cands.length}`
+ 'the card cannot tell "no geometry" from "client forbids all of it" without this number');
}]);
}
return tests;
}

View File

@ -22,6 +22,9 @@
import { SailRig, orderRing } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
// gate 2 (SPRINT17): one validator for the constraint shapes, shared with the
// session that enforces them — audit and enforcement must not disagree.
import { validateNightConstraint } from '../../web/world/js/rigging.js';
/** Audit knobs, in one place so both front-ends and any future site agree. */
export const AUDIT = {
@ -37,6 +40,17 @@ export const AUDIT = {
* A line with a marginal corner is flagged, never sold as a clean PASS.
*/
MARGIN: 0.15,
/**
* SPRINT17 gate 0.3 the sweep's FABRIC CHARTER, in the knobs instead of
* hardcoded twice (here and gardenfly.js). Every sweep and every garden
* flight is knitted shade cloth (0.30), the fabric a competent player takes
* into a windy night; the F key never enters an audit. That was always true
* and never SAID D's soaker finding: the card read WINNABLE over an
* all-DEAD cloth garden and the silence read as "the night is broken".
* scorecard.js names the charter on every score now (fabricCharter);
* this constant is what it names.
*/
POROSITY: 0.30,
};
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
// settle they parameterised. In the real game the rig does not exist before
@ -90,11 +104,22 @@ export const tierFor = (peakN, ratingHint = 1) =>
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
* pattern) called with the sweep's wind so live
* tree-sway closures sample the storm being flown
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
* @param {Array} [o.constraints] the night's client constraints (A's shapes;
* SPRINT17 gate 2) bans shrink the candidate
* list, a cap shrinks the budget, and the result
* says how much of each. A card that recommends a
* forbidden line is the card lying.
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best },
* budget, constrainedOut }}
*/
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null, constraints = [] }) {
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
const cands = con.cands;
if (!cands.length) {
return { cands, rows: [], winners: [], marginalWinners: [],
verdict: { ok: false, code: 'no-cover', best: null },
budget: con.budget, constrainedOut: con.dropped };
}
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
// is a bug some harness shipped:
@ -114,8 +139,43 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind }));
return judgeSweep(cands, rows);
const rows = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind, budget: con.budget }));
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
}
/**
* SPRINT17 gate 2 [B] the night's client constraints, applied to the
* sweep's inputs. ONE function shared by the sync and async drivers, because
* their purity contract ("every number equal, to the byte") extends to which
* candidates exist and what budget prices them.
*
* noAnchorFamily drops every candidate that touches a banned-family anchor
* including the site's pinned separation line, if it does:
* the pin is a fact about the YARD, but tonight's card
* recommends lines for tonight's CLIENT, and a card that
* recommends a forbidden line is the card lying.
* budgetCap caps the budget that decides `affordable`/`clean` the
* client caps the invoice, not your wallet, so the numbers
* below the cap don't move; lines above it stop being
* answers.
*
* Shapes validated at the door (rigging.js's validator the same teeth the
* session bites with, so the audit and the enforcement can never disagree
* about what a constraint means).
*
* @returns {{ cands, budget, dropped }}
*/
export function applyNightConstraints({ cands, anchors, constraints = [] }) {
for (const c of constraints) validateNightConstraint(c);
let budget = START_BUDGET, out = cands, dropped = 0;
const fams = new Set(constraints.filter((c) => c.kind === 'noAnchorFamily').map((c) => c.family));
if (fams.size) {
const banned = new Set(anchors.filter((a) => fams.has(a.type)).map((a) => a.id));
out = cands.filter((c) => !c.ids.some((id) => banned.has(id)));
dropped = cands.length - out.length;
}
for (const c of constraints) if (c.kind === 'budgetCap') budget = Math.min(budget, c.cap);
return { cands: out, budget, dropped };
}
/**
@ -166,9 +226,10 @@ export function findCandidates({ anchors, bed, siteDef = null }) {
* same wind at the same seconds, chunking cannot change a number the
* scorecard selftest asserts exactly that (chunked === sync, to the byte).
*/
export function priceCandidate(cnd, { anchors, stormDef, wind }) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BUDGET }) {
// shade cloth (AUDIT.POROSITY): the fabric a competent player takes into a
// windy night — the sweep charter, named on the card since SPRINT17 gate 0.3
const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
@ -196,10 +257,14 @@ export function priceCandidate(cnd, { anchors, stormDef, wind }) {
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
// `budget` is START_BUDGET on an unconstrained night and the client's cap on
// a capped one (gate 2): the loads and tiers above never move — the client
// caps the invoice, not the physics — but a line the cap can't buy stops
// being an answer.
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET };
affordable: !unholdable.length && hw <= budget,
clean: cleanHw != null && cleanHw <= budget };
}
/**
@ -250,20 +315,25 @@ export function judgeSweep(cands, rows) {
* @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
*/
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
onProgress = null, yieldEvery = 1 }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
onProgress = null, yieldEvery = 1, constraints = [] }) {
const con = applyNightConstraints({ cands: findCandidates({ anchors, bed, siteDef }), anchors, constraints });
const cands = con.cands;
if (!cands.length) {
return { cands, rows: [], winners: [], marginalWinners: [],
verdict: { ok: false, code: 'no-cover', best: null },
budget: con.budget, constrainedOut: con.dropped };
}
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = [];
for (let i = 0; i < cands.length; i++) {
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind }));
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind, budget: con.budget }));
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
}
return judgeSweep(cands, rows);
return { ...judgeSweep(cands, rows), budget: con.budget, constrainedOut: con.dropped };
}
/**

View File

@ -0,0 +1,237 @@
{
"id": "site_04_pool_yard",
"name": "The Pool Yard",
"blurb": "Fourteen perfect fence posts and not one of them is yours to use. The only steel standing where the shade wants a corner went in before the pool did.",
"_design": [
"THE YARD WHERE EVERYTHING SAYS NO EXCEPT THE THING THAT SHOULD NOT.",
"ROADMAP arc-2 thesis: fence compliance + corroded cheap hardware. The pool ring is",
"FOURTEEN honest noes (every post tie_off:false in the GLB — a certified pool barrier",
"is not an anchor, at any rating, ever); the corroded post c1 is the one thing that",
"DOES adopt on the east side, at 0.55, and it stands exactly where the bed's NE",
"corner wants steel. That is not an accident, it is the whole level:",
" - the bed is poolside; every 92%-cover quad in the band routes through c1.",
" Full shade in this yard has rust in it, or it does not exist (measured, SCORE IT:",
" the three 92% lines are t1,p1,p2,c1 / t1,t1b,p2,c1 / t1b,p1,p2,c1 — no others).",
" - the honest steel (gum t1/t1b, posts p1,p2,p3, the familiar 0.35 fascia) tops out",
" at 58% cover for $40 — the cheap safe rig leaves the bed's pool corner open.",
" - c1 is SHAPE-SENSITIVE, and that is the corroded tier working: the same drooped",
" eye is clean at $65 rated on t1,p1,p2,c1, and UNHOLDABLE AT ANY PRICE on the two",
" quads that pull it harder (t1,t1b,p2,c1 and t1b,p1,p2,c1). You cannot read which",
" off the object — E built the rung so you gamble rather than compute (their 0.55",
" reasoning, THREADS S17), and this yard is that gamble with a fence around it.",
"WIND: the venturi is the channel between the house's east end and the pool enclosure",
"— real geometry, gate-side, running N-S (axis 1.571, gain 1.4, r 5, throat (3.1,-3.5)).",
"It bites c1 and h3: the two lie-tiers are the two corners in the wind. The wreck",
"apron is PAID FOR (E's placement facts): c1 falls +Z ~3.9 m and the shaft lands in",
"the path between bed (x<=2.5) and ring (x>=3.7) — clear of both by ~0.3 m each side.",
"One corroded post ON PURPOSE: two would share the baked collateral key",
"(structFor/wreckStructure match the first entry; C's envelope dedupes keys), so the",
"second failure would bill and wreck the wrong post. Filed in THREADS [D] S17 —",
"a second rust rung in one yard needs its own key plumbing first."
],
"_why": [
"Authored COLD in the yard editor, SPRINT17 gate 3.2 — palette shapes, editor",
"placement, iterative SCORE IT (three passes: 2 quads -> tightened to band -> 13).",
"Every number below is the SCORE IT card, storm_03_southerly, FUNNEL ON, 9 dressed",
"anchors, export-clone world, 136.5 s flight. Confirmed by audit.html over the saved",
"file (same engine, second route) before shipping.",
" WINNABLE at $80 — 13 quads in band, 6 clean, 1 marginal.",
" cheapest honest line h1,t1b,p2,p3 — $40 hardware (+$15 spare), 45 m2, 58% cover,",
" garden 92.7 FULL, +9.0 HP over bare. No rust in it.",
" the rust gate full cover exists ONLY through c1 (92% x3, above); cheapest",
" clean rust line t1,p1,p2,c1 $65 — corrosion prices as +$25 of",
" hardware you must rate UP, plus a $45 stake on a 0.55 eye.",
" margin flags c1 4% headroom on h1,h2,p2,c1 (wrecks the corroded post $45);",
" p1 2% / t1b 2% / t1 4% — the 15% rule, marginal is not PASS.",
" collateral exposure gutter $90 (h1-h3) + corroded post $45 (c1) = $135 from",
" anchors; board exposure $160 with the gnome (exposureOf).",
" bare bed 83.7 FULL — the southerly cannot kill an open bed (its pea",
" hail takes 10.7 HP). The sail here is for the STEEL and the",
" collateral, per A's S17 gate-0.4 ruling — which is this",
" yard's thesis said as canon."
],
"yard": {
"width": 24,
"depth": 16
},
"sun": {
"elevationDeg": 55,
"azimuthDeg": -125
},
"gardenBed": {
"x": 0,
"z": 1.5,
"w": 5,
"d": 3.5
},
"house": {
"model": "house_yardside_v1",
"wreckedModel": "house_yardside_wrecked_v1",
"collateralKey": "gutter",
"collateralValue": 90,
"collateralLabel": "the gutter",
"x": -3,
"z": -6,
"anchors": [
{
"id": "h1",
"node": "fascia_anchor_01",
"type": "house",
"work": "bracket"
},
{
"id": "h2",
"node": "fascia_anchor_02",
"type": "house",
"work": "bracket"
},
{
"id": "h3",
"node": "fascia_anchor_03",
"type": "house",
"work": "bracket"
}
]
},
"structures": [
{
"id": "c1",
"model": "sail_post_corroded_v1",
"wreckedModel": "sail_post_corroded_wrecked_v1",
"x": 3.1,
"z": -2,
"rotYDeg": 0,
"solid": true,
"collateralKey": "corroded_post",
"collateralValue": 45,
"collateralLabel": "the corroded post",
"anchors": [
{
"id": "c1_a1",
"node": "top_anchor",
"type": "corroded_post",
"work": "cloth"
}
]
},
{
"id": "pool",
"model": "pool_kit_01_v1",
"x": 6.5,
"z": 1.5,
"rotYDeg": 0,
"solid": true,
"anchors": []
}
],
"trees": [
{
"id": "t1",
"model": "tree_gum_02_v1",
"x": -6.8,
"z": 2,
"phase": 1.7,
"trunkH": 3.8,
"anchorY": 3.1,
"anchors": [
{
"id": "t1",
"node": "branch_anchor_01",
"type": "tree",
"work": "cloth"
},
{
"id": "t1b",
"node": "branch_anchor_02",
"type": "tree",
"work": "cloth"
}
]
}
],
"posts": [
{
"id": "p1",
"x": -3.4,
"z": 4.6,
"h": 4,
"type": "post",
"work": "cloth"
},
{
"id": "p2",
"x": 3.4,
"z": 4.6,
"h": 4,
"type": "post",
"work": "cloth"
},
{
"id": "p3",
"x": -5.8,
"z": -1.5,
"h": 4,
"type": "post",
"work": "cloth"
}
],
"fence": {
"sides": [
"north",
"south",
"east",
"west"
]
},
"shed": {
"model": "shed_01_v1",
"x": -9.5,
"z": -5.5,
"rotYDeg": -90
},
"shedTable": {
"model": "shed_table_v1",
"x": -6.5,
"z": 7,
"rotYDeg": -90,
"pickupNode": "pickup_anchor"
},
"gnome": {
"model": "garden_gnome_01_v1",
"x": 4.6,
"z": 4.4,
"rotYDeg": 0,
"collateralValue": 25
},
"wind": {
"_venturi": [
"The pool-terrace channel: house east end to pool enclosure, gate side, running N-S.",
"Axis 1.571 is the channel's GEOMETRY (a line, mod pi — site_02's reconciled lesson),",
"not a storm heading. Gain 1.4 / r 5 puts c1 (1.4 m off-throat) and h3 (3.7 m) in the",
"wind: the corroded post is the venturi post, which is the temptation priced."
],
"venturi": [
{
"x": 3.1,
"z": -3.5,
"axis": 1.571,
"gain": 1.4,
"radius": 5,
"sharp": 3
}
]
},
"_separation_finding": [
"SPRINT17 gate 3.2 [D] — a separation pin was ATTEMPTED and honestly REFUSED, the",
"third refusal in the family (site_02, site_03, now here) and the first authored",
"AFTER A's gate-0.4 ruling made the pattern canon. Measured (SCORE IT, funnel ON,",
"storm_03_southerly, 2026-07-21): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP);",
"best flown 92.7 FULL (h1,t1b,p2,p3, $40); best gain +9.0 HP. bareMustLoseBelow",
"cannot be written at any threshold — bare WINS the night by 33.7 over the win",
"line. On this night the sail is for the steel and the collateral (A, S17 0.4,",
"ruled canon, taught by the board's exposure line): what c1 separates is not the",
"bed from the hail but $45 and your margin from your greed. When B's garden-BONUS",
"separation shape lands (A ruled FOR it, deferred to its own gate), re-measure —",
"this yard's +9.0 over bare at $40 is a bonus-stake candidate, not a win/lose one."
]
}

View File

@ -73,6 +73,13 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
"carport_01/post_anchor_02"
]
},
"corroded_post": {
"collateral": "corroded_post",
"rating_hint": 0.55,
"sources": [
"sail_post_corroded/top_anchor"
]
},
"house": {
"collateral": "gutter",
"rating_hint": 0.35,

View File

@ -240,6 +240,29 @@ export const POOL = [
+ 'working, it stops you buying your way out of choosing. The carport at $180 is the '
+ 'tension — the rig this cap pushes you toward is the one with the least margin over it.',
},
{
// SPRINT17 gate 3.2 [D] — THE POOL YARD. The first yard whose ONLY route
// into the game is being chosen: it is in no NIGHTS slot, so declining it
// forever is a complete campaign. That is the arc-2 promise made literal,
// and it is why the entry lives here and not in week.js.
id: 'pool_yard_southerly',
storm: 'storm_03_southerly', site: 'site_04_pool_yard',
client: 'the Karalis place', addr: '5 Clearwater Ct — the pool yard',
brief: 'New pool, new fence, certified in March — and the certifier is back Thursday, so '
+ 'nothing touches that fence. Not a rope, not a clamp. The old sail post from before '
+ 'the pool went in is still standing, they say, and they say it like a selling point. '
+ 'It has been breathing pool air for years. Tuesday\'s southerly, the bed\'s by the water.',
_why: 'AUTHORED COLD + AUDITED (D, S17 gate 3.2 — SCORE IT card, funnel ON, confirmed by '
+ 'audit.html over the shipped file): 13 quads in band, 6 clean, 1 marginal; cheapest '
+ 'hold $40 (h1,t1b,p2,p3 → garden 92.7 FULL, 58% cover, no rust in it). The trap is '
+ 'priced, not padded: every 92%-cover line routes through the corroded post c1 (0.55, '
+ '$45 collateral, IN the venturi) — clean at $65 rated on t1,p1,p2,c1, UNHOLDABLE on '
+ 'the two shapes that pull the eye harder. Southerly = the storm nights 2 and 4 teach; '
+ 'the yard is the only new variable (one-variable law). Separation REFUSED with '
+ 'receipts in the site file (bare 83.7 FULL — mild-night canon per A\'s 0.4 ruling); '
+ 'exposure $160 (gutter 90 + corroded post 45 + gnome 25), the fence\'s fourteen '
+ 'posts deliberately absent from that number because none of them can be tied to.',
},
];
/**

View File

@ -89,9 +89,21 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
* concrete would notice (1.00). One rating for the whole type, per the
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
*
* `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty the pool
* yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`,
* and this time the word isn't about the footing (the footing is fine
* concrete, same as ever): it's about the STEEL. "post" promises sound
* galvanised tube; this is eaten tube whose visible bloom is a floor on the
* damage, not a ceiling. rating_hint 0.55 above the swing frame (footed
* beats loose), below the pergola (sound flexing timber beats steel you
* can't inspect the inside of). One rating for the whole type, per the
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
* string is the player's pre-rig read of the same fact.
*/
export const ANCHOR_TYPE = Object.freeze([
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
'corroded_post',
]);
// ---------------------------------------------------------------------------

View File

@ -186,6 +186,13 @@ function boot() {
function render(out, s, ms) {
out.replaceChildren();
// gate 2 (SPRINT17): the budget every affordability sentence on this card is
// denominated in — START_BUDGET on an open night, the client's cap on a
// capped one. The engine decided with this number; the card must print with
// the same one or the two tell different stories.
const BUDGET = s.budget ?? START_BUDGET;
const capped = BUDGET < START_BUDGET;
// ── the header card: WHAT WAS SCORED, and the funnel state, loudly ────────
// Sprint 11 shipped a headline built with the funnel off, and the number
// looked entirely reasonable. Three harnesses made that mistake because
@ -197,6 +204,20 @@ function render(out, s, ms) {
kv(head, 'venturi', s.funnelOn
? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
: 'none declared in site.wind');
// SPRINT17 gate 0.3 [B, A consulted] — the fabric charter, printed on EVERY
// card, not only where somebody already suspects a problem: "a disclosure
// that appears only where somebody already knows there's a problem teaches
// that its absence means no problem" (A's consult, adopted). Same discipline
// as the funnel row above — a score whose fabric you can't see is a rumour.
kv(head, 'fabric', `${s.fabric.name} (sweep charter)`, s.fabricLeaks ? 'ed-warn' : null);
if (s.fabricLeaks) {
// D's soaker finding, worded with A: name what is MISSING from the card,
// not what key to press — the binding can move, the missing line can't.
head.append(el('div', 'ed-note ed-warn',
'Scored on SHADE CLOTH, and tonight\'s stones pass that weave — on this night the '
+ 'fabric is the bet, and the audit does not place it. The membrane line is not in '
+ 'these numbers: a DEAD garden table below means the cloth loses, not the night.'));
}
kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`,
s.dressed ? 'ed-ok' : 'ed-err');
if (!s.dressed) {
@ -206,6 +227,17 @@ function render(out, s, ms) {
+ 'are not what ships. Fix the page before believing this card.'));
}
kv(head, 'bed', `${s.bed.w}×${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`);
// gate 2: the client's terms this score ran under, on the header with the
// other things that change what the numbers MEAN (funnel, fabric). Nothing
// prints on an unconstrained score — absence must keep meaning absence.
if (s.constraints?.length) {
for (const c of s.constraints) kv(head, 'client terms', `${c.label}`, 'ed-warn');
if (s.constrainedOut > 0) {
head.append(el('div', 'ed-note ed-warn',
`${s.constrainedOut} line(s) in the audit band touch banned steel and were not scored — `
+ 'they are not answers on this night, whatever they hold.'));
}
}
kv(head, 'took', `${(ms / 1000).toFixed(1)} s`);
// ── 1. winnability at $80 ────────────────────────────────────────────────
@ -213,16 +245,22 @@ function render(out, s, ms) {
const vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err';
const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY',
unaffordable: '✗ UNAFFORDABLE', 'no-cover': '✗ NO LINE COVERS THE BED' }[V.code] ?? V.code;
const cw = card(out, `${vhead} — at ${money(START_BUDGET)}`, vcls);
const cw = card(out, `${vhead} — at ${money(BUDGET)}${capped ? ' (client cap)' : ''}`, vcls);
kv(cw, 'quads in band', `${s.cands} candidate${s.cands === 1 ? '' : 's'}`);
kv(cw, 'clean lines', String(s.winners.length), s.winners.length ? 'ed-ok' : 'ed-err');
kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null);
if (V.code === 'no-cover') {
cw.append(el('div', 'ed-note ed-err',
'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an '
+ 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — '
+ 'a yard failing here is failing on geometry, not on the heuristic.)'));
cw.append(el('div', 'ed-note ed-err', s.constrainedOut > 0
// gate 2: same verdict code, different fact — a yard the BAN emptied is
// not a yard with no geometry, and saying so tells the author which
// knob is wrong (the constraint's, not the yard's).
? `Every quad that shades the bed touches banned steel (${s.constrainedOut} removed by the `
+ 'client\'s terms). The yard has geometry; this CLIENT forbids all of it — a night the '
+ 'board must not offer, or a constraint that needs re-ruling.'
: 'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an '
+ 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — '
+ 'a yard failing here is failing on geometry, not on the heuristic.)'));
} else if (V.code === 'marginal-only') {
const b = V.best;
cw.append(el('div', 'ed-note ed-warn',
@ -234,7 +272,7 @@ function render(out, s, ms) {
const b = V.best;
cw.append(el('div', 'ed-note ed-err',
`No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a `
+ `${money(START_BUDGET)} budget`
+ `${money(BUDGET)}${capped ? ' (client-capped)' : ''} budget`
+ (b.unholdable.length
? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shops ceiling at any price.`
: '.')));
@ -249,14 +287,15 @@ function render(out, s, ms) {
if (ch) {
kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? ' 📌 pinned' : ''));
kv(cc, 'hardware', money(ch.row.cleanHw) + (ch.row.hw < ch.row.cleanHw ? ` (holds at ${money(ch.row.hw)})` : ''));
kv(cc, '+ spare', money(ch.total), ch.total <= START_BUDGET ? 'ed-ok' : 'ed-warn');
kv(cc, '+ spare', money(ch.total), ch.total <= BUDGET ? 'ed-ok' : 'ed-warn');
kv(cc, 'area', `${ch.row.area.toFixed(0)}`);
kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`,
ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err');
kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`);
if (ch.total > START_BUDGET) {
if (ch.total > BUDGET) {
cc.append(el('div', 'ed-note ed-warn',
`Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare — `
`Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare`
+ `${capped ? ' under the client\'s cap' : ''}`
+ 'a night with no spare is a repair the player cannot make.'));
}
} else {

View File

@ -331,6 +331,11 @@ const CSS = `
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
/* SPRINT17 [C]: the weather band. The hail line is lit brighter than the rest
because the soaker's whole trap is that its wind reads gentle the one line
a player must not skim past. */
#hud-card .offer .line.wx { color:#8ba0ad; }
#hud-card .offer .line.hail span { color:#cbd8e0; }
/* The two numbers a tradie decides on. The fee is the reason to say yes and
the exposure is the reason to say no, so they are weighted against each
other rather than both being grey: money in, money at risk. */
@ -816,14 +821,17 @@ export function createHud(d) {
const offerHtml = (o, i) => {
const n = o.night;
const cons = o.constraints ?? [];
const f = o.forecast;
// SPRINT17 [C], applied by the integrator: the offer maps C's ORDERED
// band (wind · hail · rain · change · confidence) instead of picking
// two forecast fields, so an offer card can never hide a night's hail.
// The .hail line is lit brighter because it is the one a player must
// not skim past — the soaker's whole trap is a gentle-reading wind.
return `
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
<div class="who">${n.client ?? 'no client'}</div>
<div class="addr">${n.addr ?? ''}</div>
${f?.wind ? `<div class="line"><span>${f.wind}</span></div>` : ''}
${f?.stones ? `<div class="line"><span>${f.stones}</span></div>` : ''}
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
${o.fee != null ? `<div class="line fee"><span>the job pays${
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
}</span><b>$${o.fee}</b></div>` : ''}

View File

@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig, spawnYawFor } from './camera.js';
import { loadStorm, createWind, forecastLines } from './weather.js';
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
@ -1041,6 +1041,12 @@ export async function boot(opts = {}) {
// C's honest lines at lead 0 — tonight, exact. The same call the job
// sheet makes, so the board can never be more confident than the sheet.
forecast: def ? forecastLines(def, 0) : null,
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer
// card maps, so a night's hail can never be dropped by the card picking
// two fields. Night 7 read softer than the buster on wind+stones alone
// (55 vs 76 km/h) while carrying 6× the hail — the board recommending
// its own trap. offerBand carries every fact the storm has.
band: def ? offerBand(def, 0) : null,
fee: def ? calloutFee(def, week.rep, night.constraints) : null,
exposure: exp.total,
exposureItems: exp.items,

View File

@ -113,18 +113,109 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true };
const fail = (reason) => ({ ok: false, reason });
/**
* SPRINT17 gate 2 [B] CLIENT CONSTRAINTS: A's data, this file's teeth.
*
* The shapes are A's seam contract (THREADS 2026-07-21, posted before either
* side was load-bearing), verbatim:
*
* { kind:'noAnchorFamily', family:'house', label:'…', says:'…' }
* { kind:'budgetCap', cap:45, label:'…', says:'…' }
*
* `label` is what the papers print (A's half); `says` is the client's own
* voice, and it is what this session's refusals return the ticker on a
* refused pick is the CLIENT talking, not the software. `family` is an
* ANCHOR_TYPE to refuse; `cap` is a hard ceiling on the night's SPEND (the
* client caps the invoice, not your wallet), enforced where the spending
* happens and asserted again at commit.
*
* The enum is CHECKED on both ends of the seam on purpose: A's
* validateConstraint guards the data source (board.js), this guards the
* enforcement door a constraint that arrives here malformed is a caller bug
* (setHardware's class, so it THROWS and names the problem), never a player
* mistake to ticker at.
*/
export const CONSTRAINT_KINDS = Object.freeze(['noAnchorFamily', 'budgetCap']);
export function validateNightConstraint(c) {
if (!c || typeof c !== 'object') {
throw new TypeError(`night constraint must be an object, got ${JSON.stringify(c)}`);
}
if (!CONSTRAINT_KINDS.includes(c.kind)) {
throw new TypeError(
`unknown constraint kind "${c.kind}" — known: ${CONSTRAINT_KINDS.join(', ')}. `
+ 'An unenforced enum is decoration (the carport-typed-as-a-post scar); if A shipped a '
+ 'new kind, the session needs its enforcement written before a night can carry it.');
}
if (typeof c.says !== 'string' || !c.says || typeof c.label !== 'string' || !c.label) {
throw new TypeError(
`constraint "${c.kind}" is missing says/label — a constraint with no client words `
+ 'renders an empty quote block on two papers and an empty refusal ticker here.');
}
if (c.kind === 'noAnchorFamily' && (typeof c.family !== 'string' || !c.family)) {
throw new TypeError('noAnchorFamily with no family bans nothing — decoration.');
}
if (c.kind === 'budgetCap' && !(Number.isFinite(c.cap) && c.cap > 0)) {
throw new TypeError(`budgetCap cap must be a positive number, got ${JSON.stringify(c.cap)}`);
}
return c;
}
export class RiggingSession {
/**
* @param {object} opts
* @param {Array} opts.anchors world.anchors [{id, pos, type, sway?}]
* @param {number} opts.budget starting cash
* @param {Array} opts.constraints the night's client constraints (A's
* shapes, validated here) see setConstraints
*/
constructor({ anchors = [], budget = START_BUDGET } = {}) {
constructor({ anchors = [], budget = START_BUDGET, constraints = [] } = {}) {
this.anchors = anchors;
this._startBudget = budget;
this.setConstraints(constraints);
this.reset();
}
/**
* The client's terms for tonight (gate 2). Validated at the door a
* malformed constraint throws with the kind named, because by the time one
* reaches the session it has already passed A's board validator, so a bad
* one here is a caller bug, not data.
*
* SURVIVES reset() deliberately, like `_startBudget`: "play again" on the
* same night replays the same client. The wiring contract (filed for A, the
* setBudget pattern): main.js calls
* `session.setConstraints(week.job?.constraints ?? [])` at the same night
* boundary that re-banks the shop the `?? []` is what clears the previous
* client's terms, so an unconstrained night cannot inherit a ban.
*/
setConstraints(list = []) {
for (const c of list) validateNightConstraint(c);
this._constraints = Object.freeze([...list]);
return this;
}
get constraints() { return this._constraints; }
/** The ban that covers this anchor, or null. (gate 2, noAnchorFamily) */
_banFor(anchor) {
return this._constraints.find((c) => c.kind === 'noAnchorFamily' && c.family === anchor.type) ?? null;
}
/**
* The cap refusal for spending `amount` more, or null. (gate 2, budgetCap)
*
* Checked at SPEND time, not only at commit: a cap the player discovers
* four corners deep is a restart wearing a rule's name, and the refusal
* reason is the client's own words so the ticker reads as the client
* querying the invoice, mid-shop. Refunds are always allowed the way BACK
* under the cap must never be blocked by the cap.
*/
_capRefusal(amount) {
if (!(amount > 0)) return null;
const c = this._constraints.find((x) => x.kind === 'budgetCap');
if (!c) return null;
return (this.spent + amount > c.cap) ? fail(c.says) : null;
}
/**
* Back to an empty prep phase, same anchors and starting budget. Lane A's
* "play again" reaches into the state machine to fake a fresh round rather
@ -172,7 +263,11 @@ export class RiggingSession {
return this.reset();
}
get spent() { return START_BUDGET - this.budget; }
// Against _startBudget, not the global (gate 2's incidental fix): the two
// are identical on every shipped night, but a re-banked session measuring
// its spend against the DEFAULT bank would misprice the cap check the
// moment those diverge — the same class of quiet lie as the frozen sway.
get spent() { return this._startBudget - this.budget; }
get canStart() { return this.picks.length === MAX_CORNERS; }
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
@ -188,8 +283,14 @@ export class RiggingSession {
rig(anchorId) {
const a = this.anchors.find((x) => x.id === anchorId);
if (!a) return fail('no such anchor');
// gate 2: the house ban — refused in the CLIENT's words. The ticker is the
// client talking ("Nothing goes on the house…"), not the software.
const ban = this._banFor(a);
if (ban) return fail(ban.says);
if (this.isRigged(anchorId)) return fail('already rigged');
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
const capNo = this._capRefusal(HARDWARE[0].cost);
if (capNo) return capNo;
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
this.picks.push({ anchorId, hw: HARDWARE[0] });
this._reorder();
@ -214,6 +315,8 @@ export class RiggingSession {
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
const capNo = this._capRefusal(next.cost - p.hw.cost);
if (capNo) return capNo;
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
p.hw = next;
return OK;
@ -247,6 +350,8 @@ export class RiggingSession {
}
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const capNo = this._capRefusal(hw.cost - p.hw.cost);
if (capNo) return capNo;
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw;
return OK;
@ -260,6 +365,8 @@ export class RiggingSession {
setFabric(f) {
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
const capNo = this._capRefusal(pick.cost - this.fabric.cost);
if (capNo) return capNo; // both fabrics are $0 today; the cap covers the day one isn't
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
this.fabric = pick;
return OK;
@ -275,6 +382,12 @@ export class RiggingSession {
setSpares(n) {
n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST;
// gate 2: the cap covers spares too — the client caps the INVOICE, and the
// spare is on it. "$40 of steel + a $15 spare" under a $45 cap is the
// constraint's own squeeze, said out loud here rather than discovered at
// settlement.
const capNo = this._capRefusal(delta);
if (capNo) return capNo;
if (!this._spend(delta)) return fail('not enough budget');
this.spares = n;
return OK;
@ -327,6 +440,26 @@ export class RiggingSession {
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
commit(rig) {
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
// gate 2, the promise the sprint doc names ("enforces the budget cap at
// commit"): by now the refusals above have made these states unreachable
// through the session's own API, so reaching one is a caller walking
// around the doors — setHardware's class. THROW, don't ticker: a sail
// attached over the client's terms is a bug on its way to an invoice.
for (const p of this.picks) {
const a = this.anchors.find((x) => x.id === p.anchorId);
const ban = a && this._banFor(a);
if (ban) {
throw new Error(
`commit: ${p.anchorId} is ${a.type} steel and the client's terms say "${ban.label}" — `
+ 'a pick got past the rig() refusal. Whoever built these picks skipped the session.');
}
}
const cap = this._constraints.find((c) => c.kind === 'budgetCap');
if (cap && this.spent > cap.cap) {
throw new Error(
`commit: $${this.spent} spent under the client's $${cap.cap} cap — `
+ 'a spend got past the session\'s cap refusals.');
}
// Fabric before attach: porosity scales the wind pressure term and decides
// whether the cloth ponds, so the sail has to know what it's made of before
// it is built. (It's also half of the wild night's only winnable line —

View File

@ -9,6 +9,7 @@
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';
import { factoryExtras } from './ratings.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
@ -392,6 +393,168 @@ test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence w
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`;
});
// ---------- SPRINT17 gate 2: client constraints — A's shapes, this session's teeth ----------
// The literals below are A's seam contract verbatim (THREADS 2026-07-21): the
// session must enforce exactly the shape the board ships, not a friendlier
// cousin of it. If A's shapes move, these move in the same commit or go red.
const HOUSE_BAN = {
kind: 'noAnchorFamily', family: 'house',
label: 'nothing attached to the house',
says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.',
};
const CAP_45 = {
kind: 'budgetCap', cap: 45,
label: 'client caps the rig at $45',
says: 'Cheapest option that does the job. Anything over forty-five and I\'ll be querying the invoice.',
};
const FAKE_RIG = { setFabric() {}, attach: () => 'attached' };
test('gate 2: the house ban refuses the pick in the CLIENT\'s words, charges nothing, bans a family not a yard', () => {
const s = session().setConstraints([HOUSE_BAN]);
for (const id of ['h1', 'h2', 'h3']) {
const r = s.rig(id);
assert(!r.ok, `${id} rigged under a house ban`);
assert(r.reason === HOUSE_BAN.says,
`the refusal must be the client's own words for the ticker, got "${r.reason}"`);
}
assert(s.budget === START_BUDGET, 'a refused pick was charged');
assert(s.picks.length === 0, 'a refused pick was kept');
// the rest of the yard stays open — the ban is a family, not a lockout
for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, `${id} refused without being banned`);
assert(s.canStart, 'four honest corners must still close the quad under a house ban');
assert(s.commit(FAKE_RIG) === 'attached', 'a legal rig failed to commit under the ban');
return 'h1/h2/h3 refused with the client talking, t/p corners rigged and committed';
});
test('gate 2: the cap is a wall at spend time — every path, exact at the boundary, refunds always free', () => {
const s = session().setConstraints([CAP_45]);
for (const id of ['t1', 't2', 'p1', 'p2']) assert(s.rig(id).ok, 'carabiners must fit any sane cap');
assert(s.setHardware('t1', SHACKLE).ok, 'upgrade inside the cap refused'); // $30
assert(s.setSpares(1).ok, 'spend TO the cap refused — the cap is a ceiling, not a strict bound'); // $45, exact
assert(s.spent === CAP_45.cap, `expected exactly $${CAP_45.cap} spent, got $${s.spent}`);
// every remaining spend path refuses, in the client's words, charging nothing
for (const [what, r] of [
['cycleHardware', s.cycleHardware('t2')],
['setHardware', s.setHardware('t2', RATED)],
['setSpares', s.setSpares(2)],
]) {
assert(!r.ok, `${what} spent past the client's cap`);
assert(r.reason === CAP_45.says, `${what} refusal must be the client's words, got "${r.reason}"`);
}
assert(s.spent === CAP_45.cap && s.pickOf('t2').hw === CARABINER && s.spares === 1,
'a refused spend changed state anyway');
// the way BACK under the cap is never blocked — refund, then respend
assert(s.setSpares(0).ok, 'a refund was blocked by the cap'); // $30
assert(s.setHardware('t2', SHACKLE).ok, 'freed room under the cap refused'); // $40
assert(s.commit(FAKE_RIG) === 'attached', 'a rig at $40 under a $45 cap failed to commit');
return `spent to $${CAP_45.cap} exactly, three paths refused past it, refund freed $10 of room`;
});
test('gate 2: commit is belt-and-braces — a caller who walked around the doors gets a THROW, not an invoice', () => {
// Both states below are unreachable through the session's own API (the
// refusal tests above prove the doors), so these poke the internals the way
// only a buggy caller could — and commit must refuse to attach the sail.
const s = session().setConstraints([HOUSE_BAN]);
for (const id of ['t1', 't2', 'p1']) s.rig(id);
s.picks.push({ anchorId: 'h1', hw: CARABINER }); // banned steel, no door used
let threw = null;
try { s.commit(FAKE_RIG); } catch (e) { threw = e; }
assert(threw && /h1/.test(threw.message), 'commit attached a sail to banned steel');
const s2 = session().setConstraints([CAP_45]);
for (const id of ['t1', 't2', 'p1', 'p2']) s2.rig(id);
s2.picks[0].hw = RATED; s2.picks[1].hw = RATED; // $70 of steel, budget never asked
s2.budget = START_BUDGET - 2 * RATED.cost - 2 * CARABINER.cost;
threw = null;
try { s2.commit(FAKE_RIG); } catch (e) { threw = e; }
assert(threw && new RegExp(`\\$${CAP_45.cap}`).test(threw.message),
'commit attached a sail spent past the client\'s cap');
return 'banned pick and cap overspend both throw at commit, naming the term broken';
});
test('gate 2: the constraint door is a checked enum, and the terms survive reset but not setConstraints([])', () => {
const throwsOn = (list) => {
try { session().setConstraints(list); return false; } catch { return true; }
};
assert(throwsOn([{ kind: 'noSwearing', label: 'x', says: 'y' }]),
'unknown kind accepted — the enum is decoration (the carport-typed-as-a-post scar)');
assert(throwsOn([{ kind: 'budgetCap', cap: 0, label: 'x', says: 'y' }]), 'cap $0 accepted — a soft-lock');
assert(throwsOn([{ kind: 'budgetCap', cap: NaN, label: 'x', says: 'y' }]), 'cap NaN accepted — NaN compares false and caps nothing');
assert(throwsOn([{ kind: 'noAnchorFamily', family: 'house' }]), 'a constraint with no client words accepted — empty ticker, empty papers');
assert(throwsOn([{ kind: 'noAnchorFamily', label: 'x', says: 'y' }]), 'a ban with no family accepted — bans nothing');
// persistence: reset() is "play again" on the SAME night, so the client's
// terms ride through it (the _startBudget pattern); [] is the only clear,
// which is why the wiring contract is setConstraints(job?.constraints ?? [])
const s = session().setConstraints([HOUSE_BAN]);
s.reset();
const r = s.rig('h1');
assert(!r.ok && r.reason === HOUSE_BAN.says, 'reset() dropped the client\'s terms — "play again" rigged the house');
s.setConstraints([]);
assert(s.rig('h1').ok, 'setConstraints([]) did not clear the ban — the next client inherited it');
return 'five malformed shapes thrown out at the door; terms survive reset, cleared only explicitly';
});
// --- SPRINT17 gate 3 support: THE CORRODED TIER PRICES HONESTLY -------------
// E landed the corroded post (rating_hint 0.55, its own ANCHOR_TYPE word, a
// $45 collateral); the gates-0.2/0.3/2 pass DEFERRED gate 3, and this is it.
// B owns the pricing surfaces the card reads, so the number the shop charges
// for a corroded corner is pinned HERE, off E's real resolver — not a
// hand-typed 0.55. The collateral half ($45 through world.collateralFor → the
// exposure line) is pinned in gardenfly.selftest, where a dressed world exists;
// this is the STEEL half: a corroded post is weaker, so the same load costs
// MORE steel to hold, which is what "prices honestly" means and is B's tier
// math (_effRating), not E's mesh.
//
// ⚠️ CROSS-LANE SEAM. The corroded tier lives on lane/e; on lane/b in
// isolation `factoryExtras('corroded_post')` is null (no gen entry, no GLB).
// These pins SKIP WITH A DISCLOSURE on a bare lane/b and go live the moment
// lane/e merges — the "build against the shape as pushed" seam the sprint runs
// on. Measured GREEN on the scratch merge (lane/a + lane/e): hint 0.55.
const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const CORRODED_SKIP = 'SKIPPED — corroded tier is on lane/e, absent on bare lane/b; this pin is '
+ 'inert here and goes live at integration (measured GREEN on the scratch merge, hint 0.55)';
test('gate 3: the corroded tier is 0.55, read off E\'s resolver not a literal', () => {
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
const ex = factoryExtras('corroded_post');
assert(ex && ex.ratingHint === 0.55,
`factoryExtras('corroded_post') gave ${JSON.stringify(ex)} — the card reads this for a node-less `
+ 'corroded placement, and if E re-rules the number it must move here, not be re-typed');
assert(ex.collateral === 'corroded_post',
'the corroded anchor carries no collateral key — the exposure line would read "not scored" over a $45 trap');
return `corroded_post → hint ${ex.ratingHint}, collateral "${ex.collateral}"`;
});
test('gate 3: a corroded corner costs MORE steel than a sound post at the same load', () => {
if (!CORRODED_TIER_LIVE) return CORRODED_SKIP;
// _effRating is what the panel's arrow and the audit's tier both price
// against: hardware.rating × the anchor's ratingHint. A corroded post fails
// at 0.55 of the steel's spec, so a load a carabiner holds on a sound post
// may need a shackle on a corroded one. Built off E's real hint.
const hint = factoryExtras('corroded_post').ratingHint;
const SOUND = { id: 'ps', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 }, sway: () => ({ x: -6.4, y: 3.9, z: 7.4 }) };
const COR = { id: 'pc', type: 'corroded_post', pos: { x: 5.3, y: 3.9, z: 8 }, ratingHint: hint,
sway: () => ({ x: 5.3, y: 3.9, z: 8 }) };
const s = new RiggingSession({ anchors: [SOUND, COR, ...ANCHORS] });
s.rig('ps'); s.rig('pc'); s.rig('h1'); s.rig('t1');
const sound = s._effRating(s.pickOf('ps'));
const cor = s._effRating(s.pickOf('pc'));
// Both start on carabiners (rating 1200): the sound post holds 1200 N, the
// corroded holds 1200 × 0.55 = 660 N. Same steel, less strength — measured.
assert(sound === CARABINER.rating, `sound post effRating ${sound}, expected the carabiner's ${CARABINER.rating}`);
assert(Math.abs(cor - CARABINER.rating * hint) < 1e-9,
`corroded effRating ${cor}, expected ${CARABINER.rating} × ${hint} = ${CARABINER.rating * hint}`);
assert(cor < sound,
'a corroded post held as much as a sound one — the trap is priced as safe steel, which inverts it');
// The tier consequence: a load in the corroded gap (6601200 N) is HELD by a
// carabiner on the sound post and OVER a carabiner on the corroded one.
const load = 900;
assert(load < sound && load > cor,
`pick a load in the corroded gap (${cor}..${sound} N) so the tier difference is real`);
return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`;
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -970,6 +970,26 @@ export class SailRig {
// 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.
//
// SPRINT17 gate 0.2 — THE SECOND WIRE (D's poison poke, S16 filing). The
// loop above is the heal's only trigger and it SKIPS broken corners, so
// once all four are gone there is no corner load left to read NaN through:
// a fully-lost sail that diverged again stayed NaN forever and rendered as
// nothing. A lost sail is still cloth in the yard — it must LOOK lost, not
// vanish. The sentinel runs ONLY on a fully-broken rig (the corner wire
// covers every other case): sum the state — any NaN/±Inf makes the sum
// non-finite (InfInf is NaN), and finite cloth cannot overflow it
// (positions are metres, prev within a step of pos). Branch-free O(n)
// adds, no mutation, so a finite lost sail is untouched byte-for-byte —
// the negative control in sail.selftest.js pins that.
if (!diverged
&& this.corners[0].broken && this.corners[1].broken
&& this.corners[2].broken && this.corners[3].broken) {
const pos = this.pos, prev = this.prev;
let s = 0;
for (let i = 0; i < pos.length; i++) s += pos[i] + prev[i];
if (!Number.isFinite(s)) diverged = true;
}
if (diverged) this._healNonFinite();
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}

View File

@ -327,6 +327,51 @@ test('divergence heal: negative control — honest overload breaks never trigger
return `${broke.length} honest break(s), zero heals — the heal only answers divergence`;
});
test('divergence heal: re-arms on a fully-lost sail (D\'s second poison) — and stays silent on a finite one', () => {
// SPRINT17 gate 0.2, D's S16 filing verbatim: "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." The fix is the sentinel
// in _checkFailure — it watches ONLY when every corner is broken. This test
// is D's gait replayed: cascade, then poke the corpse.
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
const heals = [];
r.events.on('heal', (e) => heals.push(e));
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 2);
// First poison: the cascade D observed — all four corners let go by
// divergence inside the run, the S16 heal answers it.
r.pos[(r.N + 1) * 3 + 1] = NaN;
runStorm(r, w, 2);
assert(r.corners.every((c) => c.broken),
`first poison broke only ${r.corners.filter((c) => c.broken).length}/4 corners — D's cascade did not happen and this test is not testing the edge`);
assert(heals.length > 0, 'the first divergence never healed — S16 gate 2.1 regressed, this test cannot reach the edge');
const healsAfterCascade = heals.length;
// NEGATIVE CONTROL first: a fully-broken sail with FINITE state rides on
// with zero heals — the sentinel must never mistake "lost" for "sick".
runStorm(r, w, 5);
assert(heals.length === healsAfterCascade,
`${heals.length - healsAfterCascade} heal event(s) on a finite lost sail — the sentinel is touching healthy state`);
// The second poison: before the sentinel, this NaN had no wire to trip —
// no unbroken corner reads a load — and the corpse stayed poisoned forever.
r.pos[(r.N + 1) * 3 + 1] = NaN;
let steps = 0;
const cap = Math.round(1 / SIM_DT);
while (heals.length === healsAfterCascade && steps < cap) {
r.step(SIM_DT, w, 9 + steps * SIM_DT); steps++;
}
assert(heals.length > healsAfterCascade,
'second poison on a fully-broken sail never healed — the re-arm edge is back (_checkFailure has no wire when all four corners are broken)');
for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite after the re-armed heal');
for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite after the re-armed heal');
// ...and it HOLDS: a full second of sim later the corpse is still cloth.
runStorm(r, w, 1);
for (const v of r.pos) assert(Number.isFinite(v), 'lost sail went non-finite again inside 1 s — the re-armed heal did not hold');
for (const v of r.water) assert(Number.isFinite(v), 'water non-finite after the re-armed heal');
return `cascade 4/4 → 5 s finite, no heal (control) → second poison healed in ${steps} substep(s), finite 1 s on`;
});
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.

View File

@ -14,10 +14,21 @@
*/
import * as THREE from '../../vendor/three.module.js';
import { assert, assertClose, fixedLoop } from '../testkit.js';
import { assert, assertClose, assertEq, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
import {
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
forecastHonest, stormStats, STONE_WORD_CEILING,
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
// card PRINTS, and which yard a storm may be printed over.
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
} from '../weather.js';
import { loadSite, createWorld } from '../world.js';
// SPRINT17 gate 1 — the board's weather side. NIGHTS POOL is every storm an
// offer card can print; both are data, so the honesty walk below tracks them
// without a test edit the day D's pool yard lands.
import { NIGHTS, nightAt } from '../week.js';
import { POOL, exposureOf } from '../board.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,
// including on the day the game itself changed. "Two harnesses, one number"
@ -1284,4 +1295,306 @@ export default async function run(t) {
`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`);
});
// ════════════════════════════════════════════════════════════════════════
// SPRINT17 gate 1 — THE BOARD'S WEATHER SIDE (C).
//
// A's seam contract (THREADS 2026-07-21): the offer card prints my
// forecastLines(def, 0), and "a storm the forecast can't describe honestly
// doesn't get offered" is MY rule to enforce. Enforced here, over the union
// of both night sources, so a new pool entry meets the gate at integration
// without anyone remembering to ask.
// ════════════════════════════════════════════════════════════════════════
// Every storm the board can put on a card, from BOTH sources, by
// construction. A's stormsToPreload mutation finding is the warning label:
// today every POOL storm also flies in NIGHTS, so a walk of NIGHTS alone
// would be green by coincidence and rot the day D's pool yard brings a storm
// of its own. The union is taken from the shipped data, so that day changes
// this walk without anyone editing a test.
const offerStormNames = [...new Set([
...NIGHTS.map((n) => (typeof n === 'string' ? n : n.storm)),
...POOL.map((p) => p.storm),
])];
const offerDefs = {};
const offerLoadErrors = [];
for (const nm of offerStormNames) {
try { offerDefs[nm] = storms[nm] ?? await loadStorm(nm); }
catch (err) { offerLoadErrors.push(`${nm}: ${err.message}`); }
}
t.test('GATE 1 (S17): a storm the forecast cannot describe honestly is not offered', () => {
assert(offerLoadErrors.length === 0,
`offerable storms failed to load/validate:\n ${offerLoadErrors.join('\n ')}`);
// Vacuity guard: the union walk must actually be walking the shipped week.
assert(offerStormNames.length >= 6,
`only ${offerStormNames.length} offerable storms found — the NIGHTS POOL walk is broken`);
const failures = [];
for (const nm of offerStormNames) {
const h = forecastHonest(offerDefs[nm], nm);
if (!h.ok) failures.push(`${nm}:\n ${h.errors.join('\n ')}`);
}
assert(failures.length === 0,
'the board offers storms the forecast cannot describe honestly — pull them from POOL/NIGHTS '
+ `or fix the def (A's contract: C vetoes, A pulls):\n ${failures.join('\n ')}`);
});
t.test('GATE 1 (S17): the honesty gate can fail — a lying change and an unspeakable stone are refused', () => {
// Negative controls through the SAME predicate the gate walk uses — the
// composition a pool-only storm exercises the day one exists. (The band-
// containment loops inside forecastHonest are structural — band() cannot
// exclude the truth by construction — and are disclosed as regression
// armour in its header, not counted as coverage. THESE two are the teeth.)
const lying = structuredClone(storms.storm_03_southerly);
lying.dirCurve = lying.dirCurve.map(([tt]) => [tt, lying.dirCurve[0][1]]); // promises a change, never turns
const l = forecastHonest(lying, 'lying_southerly');
assert(!l.ok, 'a windchange the dirCurve never delivers passed the honesty gate');
assert(l.errors.some((e) => e.includes('windchange')),
`the refusal should name the lie, got:\n ${l.errors.join('\n ')}`);
const unspeakable = structuredClone(storms.storm_02b_icenight);
unspeakable.hail.size = STONE_WORD_CEILING + 0.6;
const u = forecastHonest(unspeakable, 'cricket_ball_night');
assert(!u.ok, `hail.size ${unspeakable.hail.size} passed — the vocabulary ceiling is decoration`);
assert(u.errors.some((e) => e.includes('vocabulary') || e.includes('ceiling')),
`the refusal should name the vocabulary, got:\n ${u.errors.join('\n ')}`);
// and the shipped icenight sits under the ceiling with honest headroom
assert(stormStats(storms.storm_02b_icenight).hailSize <= STONE_WORD_CEILING,
'the shipped icenight is over the vocabulary ceiling — the gate would pull a shipped night');
});
t.test('GATE 1 (S17): the offer card\'s lead-0 lines carry the measured truth', () => {
// The board prints forecastLines(def, 0) (A's pricedOffers — "the same
// call the job sheet makes"). Cross the card TEXT against stormStats'
// measured numbers, so the wording cannot drift off the measurement while
// both stay green alone. Walks the POOL: these are the cards a player
// reads about a night they have never flown.
for (const p of POOL) {
const def = offerDefs[p.storm];
const f = forecastLines(def, 0);
assert(f.confidence === '',
`${p.id}: a lead-0 offer hedges ("${f.confidence}") — tonight is exact and the card must not waffle`);
const g = f.wind.match(/gusts to ~(\d+) km\/h/);
assert(g, `${p.id}: the wind line lost its gust figure: "${f.wind}"`);
assertEq(g[1], (stormStats(def).gustPeak * 3.6).toFixed(0),
`${p.id}: the card's gust number is not the measured storm`);
const su = f.wind.match(/^sustained to (\d+)/);
assert(su, `${p.id}: the wind line lost its sustained figure: "${f.wind}"`);
assertEq(su[1], stormStats(def).sustained.toFixed(0),
`${p.id}: the card's sustained number is not the measured storm`);
}
});
t.test('GATE 1 (S17): the offer band carries every fact the storm delivers, and none it does not', () => {
// ⚠️ THE FINDING THIS CASE EXISTS FOR. A's board prints `f.wind` and
// `f.stones` — a reasonable pair — and under that pair NIGHT 7 LIES:
// soaker (scripted) gusts to ~55 km/h · fine pea stones
// early buster (alt) gusts to ~76 km/h · pea stones
// On both printed lines the soaker is the softer job, while delivering
// 14.9s of hail against 2.4s and 44 mm/hr against 16.5. `offerBand` returns
// the ORDERED LIST instead, so the forecast decides which facts a night
// carries and a card cannot drop the next one somebody adds.
for (const nm of offerStormNames) {
const def = offerDefs[nm];
const st = stormStats(def);
const lines = offerBand(def, 0);
const keys = lines.map((l) => l.key);
const text = (k) => lines.find((l) => l.key === k)?.text ?? '';
assert(keys.includes('wind'), `${nm}: no wind line on the offer band`);
assert(keys.includes('rain'), `${nm}: no rain-rate line on the offer band`);
assert(keys.includes('change'), `${nm}: no change line on the offer band`);
assert(!keys.includes('confidence'),
`${nm}: tonight's band hedged its own confidence — lead 0 is exact`);
// HAIL, BOTH DIRECTIONS — the assert night 7 needed. Present iff it hails:
// a missing line recommends the trap, an invented one teaches that the
// absence of a hail line means somebody checked.
assert(keys.includes('hail') === (st.hailSeconds > 0),
`${nm}: hails for ${st.hailSeconds.toFixed(1)}s but the band's hail line is `
+ `${keys.includes('hail') ? 'present' : 'MISSING'}`);
if (st.hailSeconds > 0) {
assert(/fine pea|pea|marble|golf ball/.test(text('hail')),
`${nm}: hail line has no stone word — "${text('hail')}"`);
assert(/\ds of it/.test(text('hail')),
`${nm}: hail line does not say how long it hails — "${text('hail')}"`);
}
// The rate is the OTHER half of the soaker's trap, and the unit ponding
// reads — its absence is what made the two-field card lie.
assert(new RegExp(`${Math.round(st.rainPeakMmPerHour)} mm/hr`).test(text('rain')),
`${nm}: peaks at ${st.rainPeakMmPerHour.toFixed(1)} mm/hr, band says "${text('rain')}"`);
if (st.changeAt != null) {
assert(text('change').includes(`${Math.round(st.changeAt)}s`),
`${nm}: changes at ${st.changeAt}s, band says "${text('change')}"`);
} else {
assert(!/\d/.test(text('change')), `${nm}: never changes, band says "${text('change')}"`);
}
for (const l of lines) {
assert(!/NaN|Infinity|undefined/.test(l.text),
`${nm}: the ${l.key} line does not word its own numbers — "${l.text}"`);
}
}
});
t.test('GATE 1 (S17): the two buster variants are TELLABLE APART on a board card', () => {
// storm_03 and storm_03b are IDENTICAL in every number the band prints
// (sustained 13.0, gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4s at size
// 0.70) and differ ONLY in when the change lands, 30s against 18s. The
// board offers exactly that pair — the buster is the drawn alternative on
// nights 5 and 7. Drop the change line and the board offers two jobs whose
// weather it has just claimed to describe, described identically, and calls
// it a choice. That is why the band is a list and not two named fields.
const a = offerBand(storms.storm_03_southerly, 0).map((l) => l.text);
const b = offerBand(storms.storm_03b_earlybuster, 0).map((l) => l.text);
const differing = a.filter((line, ix) => line !== b[ix]);
assert(differing.length > 0,
'storm_03 and storm_03b word IDENTICAL offer bands — the board would offer two '
+ 'indistinguishable jobs and call it a choice');
assert(differing.some((l) => /change/.test(l)),
`the two busters differ on ${differing.join(' / ')} — expected the CHANGE TIME, since `
+ 'that is the only thing that differs in the storms');
});
// ── The PAIRING half of gate 1. `forecastHonest` above asks whether the
// forecast can describe a STORM; a band can be perfectly honest about a storm
// and still be an invitation to a night nobody can work. NIGHTS POOL again,
// this time carrying the SITE — the dimension the storm-side walk cannot see.
const offerPairings = [
...NIGHTS.map((_, ix) => {
const n = nightAt(ix);
return { id: `night ${ix + 1}`, storm: n.storm, site: n.site };
}),
...POOL.map((p) => ({ id: `pool:${p.id}`, storm: p.storm, site: p.site })),
];
t.test('GATE 1 (S17): every offerable PAIRING respects the measured yard/storm law', () => {
assert(offerPairings.length >= 12,
`only ${offerPairings.length} pairings found — the NIGHTS POOL walk is broken`);
const refused = pairingRefusals(offerPairings);
assert(refused.length === 0,
`the board would offer ${refused.length} pairing(s) refused by measurement:\n `
+ refused.map((r) => `${r.id}: ${r.why}`).join('\n '));
// Vacuity guard, on this exact input: without it the assert above passes on
// a law that returns [] unconditionally — the failure mode I shipped in S13
// and named. Move ONE night's yard and exactly one refusal must appear.
const moved = offerPairings.map((p) =>
(p.storm === 'storm_06_soaker' ? { ...p, site: 'backyard_01' } : p));
assert(pairingRefusals(moved).length >= 1,
'moving the soaker onto backyard_01 did not trip the law — the assert above is '
+ 'passing on a gate that cannot say no');
});
t.test('GATE 1 (S17): the pairing law refuses what it was measured to refuse, and nothing else', () => {
// MINE, with the S16 receipt in the message: the soaker over backyard_01
// has no win in it (hail cover capped at ~31%, both fabrics pond-tearing).
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
'the soaker over the backyard is offerable — the ~31% hail-cover measurement has '
+ 'stopped being enforced');
assert(/31%/.test(pairingRefusal('storm_06_soaker', 'backyard_01')),
'the refusal does not carry the measurement that justifies it');
assert(pairingRefusal('storm_06_soaker', 'site_03_swing_lawn') !== null,
'the soaker over the swing lawn is offerable, and nobody has ever flown it');
assert(pairingRefusal('storm_06_soaker', 'site_02_corner_block') === null,
'the soaker over site_02 is REFUSED — that is the pairing it shipped for');
// A's two, enforced the same way and credited to A in the data.
assert(pairingRefusal('storm_02_wildnight', 'site_02_corner_block') !== null,
'the wild night is offerable off backyard_01, where its separation pin lives');
assert(pairingRefusal('storm_02b_icenight', 'site_03_swing_lawn') !== null,
'the ice night is offerable off backyard_01, where gardenBeyondSaving was measured');
assert(PAIRING_LAW.storm_02_wildnight.owner === 'A' && PAIRING_LAW.storm_06_soaker.owner === 'C',
'the law has lost track of who ruled which entry — the owner field is how a reader '
+ 'knows whose measurement to go and read');
// And it must NOT over-refuse: absence of a measurement is not a refusal.
assert(pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null,
'the southerly over the swing lawn is refused — that is night 4, the shipped ladder');
assert(pairingRefusal('storm_99_unmeasured', 'anywhere') === null,
'a storm with no entry in the law was refused — inventing a refusal for a pairing '
+ 'nobody flew is the same offence in the other direction');
});
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
// route, the S14 pin pattern (two harnesses, one number, by construction).
// Prep up front (Suite.test can't await); the tests skip honestly offline.
const crossWorlds = {};
for (const nm of ['site_02_corner_block', 'backyard_01', 'site_03_swing_lawn']) {
try {
const sj = await loadSite(nm);
const w = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: sj });
let dressed = true;
try { await w.dress(); } catch { dressed = false; } // graybox anchors carry no GLB collateral keys
crossWorlds[nm] = { sj, w, dressed };
} catch (err) {
crossWorlds[nm] = { error: err.message };
}
}
/**
* MY envelope: what a failure can actually bill, gathered the way the game
* bills it (main.js scoreRun) every collateral KEY reachable from a BUILT
* anchor, priced once per structure through world.collateralFor (the
* resolver the invoice uses), plus the gnome (billed on a two-corner
* collapse, priced off world.gnome). A's exposureOf walks the site JSON up
* front and never sees an anchor; this walks the built world and never sees
* the JSON's structure list. Two routes to one set of dollars when they
* disagree, find the variable before either is tuned; it will be the
* harness (the S14 rule, verbatim).
*/
const failureEnvelope = (w) => {
const keys = [...new Set(w.anchors.map((a) => a.collateral).filter(Boolean))];
const items = [];
const unpriced = [];
for (const k of keys) {
const p = w.collateralFor(k);
if (p) items.push({ what: p.label, cost: p.cost });
else unpriced.push(k);
}
if (Number.isFinite(w.gnome?.collateralValue)) {
items.push({ what: 'garden gnome', cost: w.gnome.collateralValue });
}
return { items, unpriced, total: items.reduce((s, i) => s + i.cost, 0) };
};
t.test('GATE 1.2 (S17): collateral exposure — two harnesses, one set of dollars', () => {
// The explicit dollar pins are the vacuity guard: two empty walks agreeing
// on $0 cannot pass here. 205 = carport 180 + gnome 25; 115 = gutter 90 +
// gnome 25; 255 = gutter 90 + swing set 140 + gnome 25 (the numbers A
// verified live on the board, and a.test pins per-structure). Every yard
// the board can currently offer is crossed — the gate asked for one.
const pins = { site_02_corner_block: 205, backyard_01: 115, site_03_swing_lawn: 255 };
for (const [nm, want] of Object.entries(pins)) {
const c = crossWorlds[nm];
if (c?.error) return `SKIPPED — no server for ${nm}: ${c.error}`;
if (!c.dressed) return `SKIPPED — ${nm} GLBs unavailable, graybox anchors carry no collateral keys`;
const mine = failureEnvelope(c.w);
const board = exposureOf(c.sj);
assertEq(mine.unpriced.length, 0,
`${nm}: anchors reach unpriced collateral (${mine.unpriced.join(', ')}) — exposure the card `
+ 'cannot show, and "not scored" must never become "free"');
assertEq(mine.total, board.total, `${nm}: the two exposure harnesses disagree — find the variable`);
assertEq(mine.total, want, `${nm}: both harnesses agree on the WRONG number`);
// Totals agreeing is necessary, not sufficient — item-level agreement is
// what rules out two errors cancelling.
const key = (xs) => xs.map((i) => `${i.what}=$${i.cost}`).sort().join(' · ');
assertEq(key(mine.items), key(board.items),
`${nm}: totals agree but the itemisation differs — the agreement is a coincidence`);
}
});
t.test('GATE 1.2 (S17): the cross has teeth — strip the beam\'s key and the harnesses disagree', () => {
const c = crossWorlds.site_02_corner_block;
if (!c || c.error || !c.dressed) return 'SKIPPED — no dressed corner block (see the cross above)';
// The drift class this cross exists to catch: an anchor that loses its
// collateral key is $180 the offer card advertises and the failure path
// can never bill (the free-carport bug, S11S14, wearing the board's
// clothes). Mutate the LIVE world — this is its last use in the suite —
// and the equality above must break, or it never could have.
const stripped = c.w.anchors.filter((a) => a.collateral === 'carport');
assert(stripped.length >= 2,
`only ${stripped.length} anchor(s) carry the carport key — the trap has lost its reach`);
for (const a of stripped) a.collateral = null;
const mine = failureEnvelope(c.w);
assertEq(mine.total, 25, `the stripped envelope should read gnome-only $25, got $${mine.total}`);
assert(mine.total !== exposureOf(c.sj).total,
'the harnesses still agree with the carport unreachable — the cross cannot fail');
});
}

View File

@ -24,6 +24,13 @@ import { loadStorm, createWind } from '../weather.js';
// file read the ladder. week.js is contracts-only underneath, so this stays
// headless-safe.
import { NIGHTS, nightAt } from '../week.js';
// SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL
// (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a
// site file this suite merely fetch()ed raw could rot invalid and stay green).
// loadSite pulls world.js which pulls THREE — browser-only, same as the GLB
// facts everywhere else in this repo; the node runner never imports this file.
import { POOL, exposureOf } from '../board.js';
import { loadSite } from '../world.js';
const DT = FIXED_DT;
@ -1229,4 +1236,96 @@ export default async function run(t) {
"the wildnight sits directly before the icenight — the icenight's brief says 'less wind than "
+ "last night', and the paper never lies: re-order these and re-word that brief in the same commit");
});
// ── SPRINT17 gate 3.2: THE POOL YARD — authored cold, entered through the ──
// board, pinned by its author. These pins carry the yard's three claims:
// it is CHOSEN or absent (never scripted), its fence is fourteen honest noes
// and its rust is one priced yes, and its refusal + wreck apron are facts
// with numbers, not vibes. The site file itself goes through loadSite here,
// so a hand-edit that breaks validateSite goes red in THIS suite too, not
// only at boot.
let poolSite = null, poolSiteErr = null;
try { poolSite = await loadSite('site_04_pool_yard'); }
catch (err) { poolSiteErr = String((err && err.stack) || err); }
t.test('pool yard (S17 3.2): in the game ONLY through the board — chosen, never scripted', () => {
const entry = POOL.find((p) => p.site === 'site_04_pool_yard');
assert(entry, 'the pool yard has a POOL entry — its one door into the game');
assertEq(entry.storm, 'storm_03_southerly',
'it flies the southerly — the storm nights 2 and 4 teach, so the yard is the only new variable; '
+ 'any other pairing needs its own gauntlet run FIRST (the _why receipt describes this one)');
assert(entry._why && entry._why.includes('audit'),
'the entry carries its audit receipt — an unmeasured pairing is a night the board is guessing about');
const scripted = NIGHTS.map((_, i) => nightAt(i));
assert(!scripted.some((n) => n.site === 'site_04_pool_yard'),
'NO scripted night flies the pool yard — "the first yard whose only route in is being chosen" '
+ 'is the arc-2 promise, and a week.js slot would quietly break it');
});
t.test('pool yard (S17 3.2): the ring says no fourteen times, the rust says yes once', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1');
assert(ring, 'the pool ring is in the yard — it IS the thesis');
assertEq((ring.anchors ?? []).length, 0,
'the ring adopts NOTHING in site data — its fourteen noes are baked tie_off:false in the GLB, '
+ 'and a site-level anchor here would override a certified fence into steel');
assert(ring.collateralValue == null,
'the ring is unpriced BY RULING (the bike rule) — nothing can bend a pool fence yet');
const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1');
assert(rust, 'the corroded post stands');
assertEq((rust.anchors ?? []).length, 1, 'exactly one adoptable eye');
assertEq(rust.anchors[0].type, 'corroded_post', 'typed to E\'s tier so it rates 0.55, not 1.00');
assertEq(rust.collateralValue, 45, 'the make-safe bill, E\'s number, adopted with its reasoning');
assert(rust.wreckedModel === 'sail_post_corroded_wrecked_v1',
'the wreck variant is wired — a trap that vanishes instead of falling is a free failure');
const rusts = poolSite.structures.filter((s) => s.model === 'sail_post_corroded_v1');
assertEq(rusts.length, 1,
'ONE corroded post on purpose: two would share the baked collateral key and the second failure '
+ 'would bill/wreck the wrong one (structFor matches first; filed in THREADS [D] S17)');
});
t.test('pool yard (S17 3.2): separation-JUDGED — refused with receipts, XOR the pin', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
const pinned = !!poolSite.separation, finding = !!poolSite._separation_finding;
assert(pinned || finding, 'the yard is JUDGED — silence is not an option (S16 gate 2.3)');
assert(!(pinned && finding), 'both at once means the finding went stale in the pinning commit');
assert(finding, 'today it is a REFUSAL: bare wins the southerly (mild-night canon, A\'s 0.4 ruling)');
const text = poolSite._separation_finding.join(' ');
assert(text.includes('83.7') && text.includes('92.7'),
'the refusal carries its measured receipts (bare 83.7 / best flown 92.7) — a finding without '
+ 'numbers is a shrug wearing a ruling\'s clothes');
});
t.test('pool yard (S17 3.2): the wreck apron threads the needle between bed and ring', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
// E's placement facts (THREADS S17 gate 3.1): the corroded post falls +Z
// ~3.9 m, shaft half-width ~0.26 m. The fallen shaft must land in the path
// — clear of the bed's east edge AND the ring's west run — or the wreck
// interpenetrates client property the moment the trap fires.
const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1');
const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1');
const bedEast = poolSite.gardenBed.x + poolSite.gardenBed.w / 2;
const shaftWest = rust.x - 0.26, shaftEast = rust.x + 0.26;
const ringWest = ring.x - 2.8; // ring proper 5.6 wide (E's measured bbox)
assert(bedEast <= shaftWest,
`bed east edge ${bedEast} overlaps the fallen shaft (west ${shaftWest.toFixed(2)}) — move c1 or the bed`);
assert(shaftEast <= ringWest,
`fallen shaft east ${shaftEast.toFixed(2)} reaches the ring (west ${ringWest.toFixed(2)}) — move c1 or the pool`);
const apronTip = rust.z + 3.9;
assert(apronTip <= poolSite.yard.depth / 2,
`the shaft falls past the south fence (tip z ${apronTip}) — it must land IN the yard, that is the point`);
});
t.test('pool yard (S17 3.2): the offer card prices the rust and never the fence', () => {
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
const { total, items } = exposureOf(poolSite);
assertEq(total, 160, 'exposure $160 = gutter 90 + corroded post 45 + gnome 25');
const labels = items.map((i) => i.what).sort().join(' · ');
assertEq(labels, 'garden gnome · the corroded post · the gutter',
'three priced items exactly — the fourteen fence posts are deliberately NOT exposure: '
+ 'nothing can bend a pool fence yet (the bike rule), and a $0 line item would teach "free"');
const rustItem = items.find((i) => i.what === 'the corroded post');
assertEq(rustItem.cost, 45,
'the rust is the cheapest structure you can break — the stake E sized the gamble in');
});
}

View File

@ -116,6 +116,16 @@ const ASSETS = [
{ name: 'pool_kit_01', h: [1.12, 1.32],
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
'fence_post_01', 'fence_post_14'] },
// SPRINT17 gate 3.1 — the corroded tier. The intact variant stands POST
// height on purpose: its lie is "I'm a sail post", so it must stand like
// one, and only the colour and the drooped eye say otherwise. The wreck's
// ceiling is under a metre because the post FOLDED at the rust line — a
// corroded wreck still standing 4 m tall is a wreck that never told the
// truth about where it was weakest.
{ name: 'sail_post_corroded', h: [3.90, 4.15],
nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] },
{ name: 'sail_post_corroded_wrecked', h: [0.45, 0.95],
nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] },
];
function sizeOf(gltf) {
@ -1050,6 +1060,273 @@ export default async function run(t) {
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
});
// -------------------------------------------------------------------------
// SPRINT17 gate 3.1 — THE CORRODED TIER. The pool kit is fourteen tie-offs
// that don't exist; this is the one that exists and shouldn't be trusted.
// The design claim is "corrosion a player can't see is a trap with no tell,
// and this repo doesn't ship those" — so the TELL itself is pinned as
// numbers (area, lightness, sag), not adjectives. An untelled corroded post
// (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and
// passes every dims/nodes check — this test is the ONLY thing that goes red
// on it, which is exactly why it exists. (Proven red in the S17 negative
// controls; receipts in THREADS.)
// -------------------------------------------------------------------------
/** Total triangle area of every mesh under a GLB scene, split by predicate
* on the mesh's material. World transforms applied; area is exact. */
const areaBy = (scene, pred) => {
scene.updateMatrixWorld(true);
let hit = 0, total = 0;
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
scene.traverse((o) => {
if (!o.isMesh) return;
const pos = o.geometry.attributes.position;
const idx = o.geometry.index;
const n = idx ? idx.count : pos.count;
let area = 0;
for (let i = 0; i < n; i += 3) {
const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1,
ic = idx ? idx.getX(i + 2) : i + 2;
a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld);
b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld);
c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld);
b.sub(a); c.sub(a);
area += b.cross(c).length() / 2;
}
total += area;
if (pred(o.material)) hit += area;
});
return { hit, total };
};
/** Rust, as a colour band: strongly saturated orange-brown, darker than
* mid. Nothing else in this palette is saturated in that hue (weathered
* steel and concrete are near-grey, s < 0.15; timber is lighter). Colours
* are read in the loader's working space thresholds chosen with margin
* on both sides (rust s 0.8, everything else 0.15). */
const isRust = (m) => {
const hsl = { h: 0, s: 0, l: 0 };
(m.color ?? new THREE.Color(0xffffff)).getHSL(hsl);
return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45;
};
// The three tells are three SEPARATE tests on purpose. Shipped as one
// block, the first failing assert masked the other two — the S17 M1
// negative control (clean steel, flat eye) went red on rust area alone and
// never reached the lightness or droop checks, so their strength was
// unproven. Split, each tell is independently red-checkable, which is the
// only way "mutation-checked" means anything.
t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (a) The at-a-glance read, as a number: rust-coloured surface area. 5%
// of a 4 m post is roughly the base band + head bloom + streaks — under
// that and the corrosion is a caption, not a tell. (Shipped value ~19%.)
const { hit, total } = areaBy(g.scene, isRust);
assert(total > 0, 'no measurable surface at all');
const frac = hit / total;
assert(frac >= 0.05,
`rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface — under 5% ` +
'is corrosion nobody can read from the lawn, a trap with no tell');
// And the honest post must NOT read rusty — the tell only works if the
// clean one is clean. (Also guards the colour thresholds themselves.)
const h = areaBy(honest.scene, isRust);
assert(h.hit === 0,
`the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area — ` +
'either the post rusted or the rust predicate is reading galvanised steel as rust');
});
t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised — base colour and PBR both moved', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
// (b) The 20 m read. This assert shipped in the S17 WIP saying the
// corroded shaft "reads darker at yard distance"; I measured the actual
// render before endorsing it and the SIGN IS BACKWARDS. Sampled off
// look.html's canvas at cam z=20 m, 1280×720, over the mid-shaft band:
//
// honest rgb(49,51,49) lum 50 sat 0.031 RB 0
// corroded rgb(98,94,86) lum 94 sat 0.126 RB +12
//
// The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is
// genuinely darker (0.524 vs 0.737) but it does not drive the pixels:
// galvanised is metalness 0.90 / roughness 0.35 and, with no environment
// map in this renderer, a near-mirror metal has almost nothing to mirror
// and goes near-black. The weathered shaft at 0.55 / 0.60 is far more
// diffuse, so it actually catches the sun. The separation is real and
// large — it is just made of METALNESS, not lightness.
//
// So the test now pins both halves, and the comment states which one the
// player is actually seeing. An assert whose stated reason is the reverse
// of the mechanism is a docstring waiting to be "fixed" in the wrong
// direction by whoever tunes this next.
const shaftOf = (gl) => {
let best = null;
gl.scene.getObjectByName('post').traverse((o) => {
if (!o.isMesh || isRust(o.material)) return;
const hsl = { h: 0, s: 0, l: 0 };
o.material.color.getHSL(hsl);
if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material };
});
return best;
};
const sHonest = shaftOf(honest), sCorroded = shaftOf(g);
assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes');
assert(sCorroded.l < sHonest.l - 0.08,
`corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)}` +
'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust');
// The half that actually reaches the screen. Weathered steel is rougher
// and less metallic than gal; if a future edit "tidies" these back to the
// galvanised values the post keeps its rust and loses its 20 m silhouette.
assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15,
`corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness}` +
'weathered steel that is as polished as gal loses the long-range read (this is the ' +
'property that actually drove lum 94 vs 50 at 20 m, measured)');
assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15,
`corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness}` +
'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts');
});
t.test('the corroded post TELLS (3/3): the pad eye DROOPS — angle derived from geometry, pinned to the baked note', () => {
const g = loaded.get('sail_post_corroded');
const honest = loaded.get('sail_post');
assert(g && honest, 'both post variants must load — the tell is a comparison');
g.scene.updateMatrixWorld(true);
honest.scene.updateMatrixWorld(true);
const root = g.scene.getObjectByName('sail_post_corroded');
const anchor = g.scene.getObjectByName('top_anchor');
assert(root && anchor, 'root and top_anchor must both survive the export');
const u = root.userData;
// THE AXIS TRAP (E's swing_set precedent). The builder writes the droop
// as a claim in THREE.JS coords; this derives the same numbers back out
// of the exported anchor position. Note and mesh can only lie together.
//
// Why not "the corroded anchor sits lower than the honest one" — the
// assert this REPLACES? Because the eye hangs off a weld line 160 mm
// down the post, so that comparison reads 0.06 m at ZERO droop and sails
// past a `> 0.05` threshold. It measured the weld offset, not the sag:
// an assert that cannot fail on the thing it names is decoration. This
// one drives the derived angle to 0° when the eye is flat.
const baked = u.padeye_droop_deg;
const arm = u.padeye_arm_m;
const weldY = u.padeye_weld_y_threejs;
assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number',
'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) — ' +
'an orientation claim that lives only in a docstring is the axis trap waiting to happen');
assert(baked > 0, `padeye_droop_deg is ${baked} — a corroded eye that does not droop has no tell`);
const p = anchor.getWorldPosition(new THREE.Vector3());
// The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0).
const horiz = Math.hypot(p.x, p.z); // out from the axis
const vert = p.y - weldY; // up from the weld
const armMeasured = Math.hypot(horiz, vert);
assert(Math.abs(armMeasured - arm) < 0.004,
`the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m — ` +
'the baked arm and the exported geometry disagree');
const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI;
assert(Math.abs(derivedDeg - baked) < 1.5,
`the exported pad eye droops ${derivedDeg.toFixed(1)}° but the root claims ${baked}° — ` +
'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)');
// DIRECTION, measured — not asserted by reading the string. Blender y
// maps to three.js +Z, so the eye must hang toward the YARD, the face the
// player judges it from. A sag pointing into the fence is a tell nobody sees.
assert(u.padeye_droop_dir_threejs === '+Z',
`the baked droop direction is ${u.padeye_droop_dir_threejs} — this test only knows how to check +Z`);
assert(p.z > 0.5 * horiz,
`the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) — the note says +Z (the yard side), ` +
'so either the exporter axis mapping moved or the note is wrong');
assert(Math.abs(p.x) < 0.006,
`the eye is displaced ${p.x.toFixed(3)} m on X — the droop is meant to be purely +Z`);
// And the weaker claim, kept as a sanity check now that it is no longer
// carrying the argument: the drooped anchor does sit below the honest one.
const sag = honest.scene.getObjectByName('top_anchor')
.getWorldPosition(new THREE.Vector3()).y - p.y;
assert(sag > 0.05,
`the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`);
});
t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => {
const T = FACTORY_ANCHOR_RATINGS.types;
const entry = T.corroded_post;
assert(entry, 'corroded_post is not in the ratings manifest — the type cannot resolve node-less');
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post,
'corroded_post is manifest-AMBIGUOUS — the one-rating-per-type rule (THREADS [E] S15) broke');
// The ladder claims, pinned against the MANIFEST, never against literals
// (a literal pin would stay green while the whole ladder reshuffled):
assert(T.swing_frame.rating_hint < entry.rating_hint,
`corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) — ` +
'a real concrete footing beats sound steel standing loose on grass');
assert(entry.rating_hint < T.pergola.rating_hint,
`corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) — ` +
'sound flexing timber beats steel whose bloom outside means pitting inside');
assert(entry.rating_hint < T.post.rating_hint,
'a corroded post rating at or above the honest post erases the tier entirely');
// It fails through the client's property: collateral named, priced, and
// priced UNDER the gutter — the bill is the make-safe, not the steel; the
// real price of trusting rust is the corner you lose mid-storm.
assert(entry.collateral === 'corroded_post',
'the manifest must carry the collateral so node-less anchors bill on failure');
const root = g('sail_post_corroded');
const gutter = g('house_yardside');
assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0,
'the corroded post names collateral nothing prices — free failure, the gutter bug');
assert(root.userData.collateral_value < gutter.userData.collateral_value,
`a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` +
`($${gutter.userData.collateral_value}) — condemned steel is a make-safe charge, not a rebuild`);
function g(name) {
const glb = loaded.get(name);
assert(glb, `${name} did not load`);
return glb.scene.getObjectByName(name);
}
});
t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => {
const w = loaded.get('sail_post_corroded_wrecked');
assert(w, 'sail_post_corroded_wrecked did not load');
const box = new THREE.Box3().setFromObject(w.scene);
// It FELL: nothing stands past the stub, and the fallen shaft reaches
// into the yard on +Z (the same face the pergola presents — placement
// facts in THREADS). Both directions measured, not asserted by name.
assert(box.max.y < 1.0,
`the wreck stands ${box.max.y.toFixed(2)} m — a corroded post that still stands never folded`);
assert(box.max.z > 3.2,
`the fallen shaft reaches z=${box.max.z.toFixed(2)} — it is meant to be ~3.5 m out in the yard (+Z)`);
// No anchor survives, nothing rates, nothing enrols — an anchor that
// outlives its structure is the free-failure bug in a costume (the
// fascia rule; fourth application).
assert(!w.scene.getObjectByName('top_anchor'),
'top_anchor survives the wreck — you cannot clip to a post that is lying on the grass');
const offers = [];
w.scene.traverse((o) => {
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
offers.push(o.name);
}
});
assert(offers.length === 0,
`the wreck offers [${offers.join(', ')}] — a wreck must offer nothing`);
// Twin-priced, and it names its twin — the carport/gutter chain, sixth
// application, compared between the GLBs and never against a literal.
const wr = w.scene.getObjectByName('sail_post_corroded_wrecked');
const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded');
assert(wr?.userData?.broken_variant_of === 'sail_post_corroded',
'the wreck must name its intact twin so A can pair the swap');
assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value,
'the wrecked post must be priced the same as the one it used to be');
assert(wr?.userData?.collateral_key === 'corroded_post',
'the wreck must keep the collateral key or the swap changes what the bill is about');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {

View File

@ -23,6 +23,30 @@ export {
const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}${fmt(b.hi)}`);
// SPRINT16 gate 3.3's word map, lifted to module scope in S17 so the honesty
// gate below audits THE map the card prints from — a predicate checking a copy
// of the vocabulary would agree with the copy forever, which is the exact drift
// this repo keeps getting bitten by. 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');
/**
* Where the stone vocabulary stops being honest. "golf ball" is the map's
* biggest word and it is open-ended upward a storm whose stones measure 2.6
* would still print "golf ball stones", underselling ice the way the numbers
* are forbidden to. The numeric bands stay truthful at any size (band() can't
* lie), but the card leads with the WORD, and a word that rules out what
* actually falls is the same ambush as a band that does. 2.0 is where the line
* sits: at 1.0 1.5 cm, 2.0 3 cm is real golf-ball territory, so the word
* holds all the way up to it; past it we are in cricket-ball country the map
* has no word for. Shipped max is the ice night's 1.4 real headroom, hard
* ceiling. A storm above this is unofferable until the vocabulary grows a word
* for it, and that is a wording sprint, not a data tweak.
*/
export const STONE_WORD_CEILING = 2.0;
/**
* How vague a night reads when it is `nightsOut` nights away the week IS the
* forecast horizon, so tonight is exact (0) and the far end of the week is as
@ -88,11 +112,8 @@ export function forecastLines(def, lead = 0) {
// 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');
// player which fabric tonight wants, before a dollar is spent. The word map
// itself is module-scope now (S17) — the offer-honesty gate audits it.
const sz = f.hail.size;
const stones = f.hail.chance === 'none' ? '' : (
stoneWord(sz.lo) === stoneWord(sz.hi)
@ -117,6 +138,272 @@ export function forecastLines(def, lead = 0) {
};
}
/**
* SPRINT17 gate 1 CAN THE FORECAST DESCRIBE THIS STORM HONESTLY?
*
* The board's rule, stated in A's seam contract and enforced here: *a storm the
* forecast can't describe honestly doesn't get offered.* An offer card leads
* with my lines if those lines can't tell the truth about a def, the def has
* no business on the board, however winnable the pairing is. c.test walks
* NIGHTS POOL through this, so a new pool entry (D's yard, arc-4 seeds)
* meets the gate at integration without anyone remembering to ask.
*
* Same contract shape as validateStorm: {ok, errors}, errors in English,
* recomputed FROM THE DEF nothing here trusts the caller's provenance, so a
* hand-edited def fails on its own merits (the checkEnvelope rule).
*
* What has TEETH here, disclosed plainly (README: an assert that cannot fail
* is decoration):
* · validateStorm structural lies (a windchange the dirCurve never
* delivers, overlapping gusts, a flatlining tail) really do fail.
* · the stone vocabulary ceiling a def with stones past STONE_WORD_CEILING
* really does fail (the icenight at hail.size 2.6 is the negative control).
* · finiteness of the measured stats a curve that produces NaN fails.
* What is STRUCTURAL and rides along as regression armour, not as a live gate:
* band-contains-truth and lead-0 exactness are guaranteed by forecastFor's
* band() today (lo=min(v,·), hi=max(v,·), width0 at lead 0) those loops can
* only fail on the day someone re-plumbs band(), which is precisely the day
* they should. Disclosed here and in THREADS rather than dressed up as
* coverage.
*
* @param {object} def parsed storm JSON
* @param {string} [name]
* @returns {{ok: boolean, errors: string[]}}
*/
export function forecastHonest(def, name = def?.name ?? 'storm') {
const errors = [];
const v = validateStorm(def, name);
if (!v.ok) errors.push(...v.errors);
if (!def || typeof def !== 'object' || !v.ok) return { ok: false, errors };
// The truth must be measurable before it can be told.
const s = stormStats(def);
for (const k of ['sustained', 'gustPeak', 'rainPeak', 'rainPeakMmPerHour',
'hailPeak', 'hailSeconds', 'hailSize']) {
if (!Number.isFinite(s[k])) {
errors.push(`${name}: stormStats.${k} is not finite — no honest line can be printed from it`);
}
}
// The vocabulary must reach the stone. Numbers band honestly at any size;
// the WORD is the card's opening argument and it must not undersell ice.
if (s.hailSeconds > 0 && s.hailSize > STONE_WORD_CEILING) {
errors.push(`${name}: hail.size ${s.hailSize} is past the stone vocabulary's ceiling `
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
+ 'grow the word map before offering this storm');
}
// Recompute the band promises from the def (structural today — see header).
const contains = (b, val, what, lead) => {
if (val == null || b == null) return;
if (!(Number.isFinite(b.lo) && Number.isFinite(b.hi))) {
errors.push(`${name}: ${what} band at lead ${lead} is not finite`);
} else if (val < b.lo - 1e-9 || val > b.hi + 1e-9) {
errors.push(`${name}: ${what} band [${b.lo}, ${b.hi}] at lead ${lead} rules out the truth ${val}`);
}
};
for (const lead of [0, 0.5, 1]) {
const f = forecastFor(def, lead);
contains(f.sustained, s.sustained, 'sustained', lead);
contains(f.gustPeak, s.gustPeak, 'gustPeak', lead);
contains(f.rain, s.rainPeak, 'rain', lead);
contains(f.rainMmPerHour, s.rainPeakMmPerHour, 'rainMmPerHour', lead);
contains(f.hail.seconds, s.hailSeconds, 'hail.seconds', lead);
contains(f.hail.size, s.hailSize, 'hail.size', lead);
contains(f.changeAt, s.changeAt, 'changeAt', lead);
if (lead === 0) {
for (const [what, b] of [['sustained', f.sustained], ['gustPeak', f.gustPeak], ['rain', f.rain]]) {
if (b.lo !== b.hi) errors.push(`${name}: lead-0 ${what} band [${b.lo}, ${b.hi}] hedges — tonight is exact`);
}
if (s.hailSeconds > 0 && f.hail.chance === 'none') {
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but tonight's card says "none"`);
}
}
}
return { ok: errors.length === 0, errors };
}
// ─────────────────────────────────────────────────────────────────────────────
// THE OFFER BAND, and THE PAIRING HALF OF THE GATE (SPRINT17 gate 1)
//
// Landed ON TOP of forecastHonest above, by the other half of Lane C this
// sprint — two sessions converged on gate 1 and split it cleanly without
// meaning to. `forecastHonest` answers "can the forecast describe this STORM",
// which is the storm-side gate and stays exactly as it landed. These two add
// the parts it does not reach: WHAT THE CARD ACTUALLY PRINTS, and WHICH YARD
// the storm may be printed over. Nothing above this line was changed.
// ─────────────────────────────────────────────────────────────────────────────
/**
* THE HONEST BAND FOR AN OFFER CARD an ORDERED LIST of lines, not a bag of
* named fields, and the shape is the whole point.
*
* **WHY A LIST, when `forecastLines` already returns every string a card
* could want.** A card that reads named fields prints the fields its author
* remembered. A's board shipped `f.wind` and `f.stones` a reasonable pair
* and that pair MEASURABLY LIES ABOUT NIGHT 7:
*
* | night 7's two offers | wind line | stones line |
* |---|---|---|
* | the soaker (scripted) | gusts to ~55 km/h | fine pea stones |
* | the early buster (alt) | gusts to ~76 km/h | pea stones |
*
* On both printed lines the soaker is the SOFTER job. It is not: it delivers
* **14.9 s of hail against 2.4 s, and 44 mm/hr of rain against 16.5**. The
* soaker's whole design is that its wind reads gentle while its hail is the
* trap (C, S16 gate 3.1) so a board printing only wind and stone size
* recommends the trap, in the storm's own voice, before a dollar is spent.
*
* The fix is not "also print rainRate". The fix is that the FORECAST decides
* which facts a night carries and hands over all of them, so the next fact
* added to a storm cannot be silently dropped by a card that predates it. Same
* construction, and the same reasoning, as A's `stormsToPreload(nights, pool)`
* split: an omission that is possible will eventually happen.
*
* **It never renders MORE than the truth either.** A hail line appears if and
* only if the storm hails "hail: none" on a hail-free night teaches that the
* absence of a hail line means nobody checked.
*
* @param {object} def parsed storm JSON
* @param {number} [lead] 0..1 the BOARD passes 0, and only 0: both offers are
* for TONIGHT. You are choosing which yard to stand in, not which night to
* work, and a hedged board would invent uncertainty the job sheet contradicts
* one card later.
* @returns {{key:string, text:string}[]} reading order: wind, hail, rain,
* change, confidence. `key` is for styling; `text` is the whole printed line.
*/
export function offerBand(def, lead = 0) {
const f = forecastLines(def, lead);
const ff = forecastFor(def, lead);
const t = f.truth;
const i = (v) => v.toFixed(0);
const lines = [{ key: 'wind', text: f.wind }];
// Hail, with its DURATION. `chance` at lead 0 is a duration threshold wearing
// a probability word (hailSeconds > 6 ⇒ 'likely'), which is honest enough on
// a job sheet and far too coarse on a board where two hail nights sit side by
// side: 'likely' covers both the soaker's 15 s and the ice night's 21 s, and
// 'possible' covers the buster's 2.4 s. The seconds are what a tradie buys
// steel against, so the seconds print.
if (f.stones) {
lines.push({
key: 'hail',
text: `hail ${f.hail.chance} · ${f.stones} · ${bandStr(ff.hail.seconds, i)}s of it`,
});
}
// The rain RATE, always — the line A's card had no field for, and half of the
// soaker's trap. mm/hr is the unit the ponding model reads, so the card and
// the sim agree on what "heavy" means instead of each having an opinion.
lines.push({ key: 'rain', text: f.rainRate });
// The change, always — including "no change forecast", which is information,
// not filler. storm_03_southerly and storm_03b_earlybuster are IDENTICAL in
// every other number this band prints (sustained 13.0, gustPeak 21.4/21.2,
// rain 16.5 mm/hr, hail 2.4 s at size 0.70) and differ ONLY in when the change
// lands, 30 s against 18 s. The board offers exactly that pair — the buster is
// the drawn alternative on nights 5 and 7. Drop this line and the board offers
// two jobs whose weather it has just claimed to describe, described
// identically, and calls it a choice.
lines.push({
key: 'change',
text: t.changeAt == null ? 'no change forecast'
: (lead > 0 ? `southerly change ${bandStr(ff.changeAt, i)}s`
: `southerly change at ${i(t.changeAt)}s`),
});
if (f.confidence) lines.push({ key: 'confidence', text: f.confidence });
return lines;
}
/** `band` under its own name — the module-scope helper, reused rather than retyped. */
function bandStr(b, fmt) { return band(b, fmt); }
/**
* **THE PAIRING LAW which yards a storm may be OFFERED over.**
*
* `forecastHonest` above asks whether the forecast can describe a STORM. This
* asks the other half of SPRINT17 gate 1's rule, the half that is a fact about
* GEOMETRY × STORM rather than about wording: a band can be perfectly honest
* about a storm and still be an invitation to a night nobody can work.
*
* **These three were three prose comments before they were a rule.** A's POOL
* carries them as a comment listing what is "deliberately NOT here" and this
* repo's standing verdict on that arrangement is A's own, on CONSTRAINT_KIND:
* an unenforced enum is decoration, and the carport shipped typed as a post for
* a sprint behind a JSDoc comment saying it couldn't. A comment cannot fail when
* somebody adds the sixth pool entry. Promoted to data, checked, red in the suite.
*
* **A two of these three are YOUR reasons and are recorded as yours.** I have
* not re-measured a separation pin or a beyond-saving flag; I have moved your
* stated grounds into a structure that goes red. Reword freely the mechanism
* is what I am defending, not the prose.
*
* An absent key means UNRESTRICTED, deliberately: this table records refusals
* somebody PAID FOR, and inventing one for a pairing nobody flew is the same
* offence in the other direction.
*/
export const PAIRING_LAW = Object.freeze({
storm_06_soaker: {
sites: ['site_02_corner_block'],
owner: 'C',
why: 'MEASURED (C, S16 gate 3.1, both shipped yards flown through gardenfly before the '
+ 'storm shipped): backyard_01\'s buyable geometry caps hail cover over the bed at '
+ '~31%, and its full-cover quads pond-tear at 3.33.7 kN in this mild wind on BOTH '
+ 'fabrics — best membrane line 37.5 against cloth 17.3, a fabric bet with no win in '
+ 'it. The soaker\'s forecast reads "fine pea stones · rain to 44 mm/hr" over there '
+ 'exactly as it does over site_02, and over there that band is an invitation to a '
+ 'night nobody can work. The swing lawn is not measured under it at all, which is '
+ 'the same refusal for a cheaper reason.',
},
storm_02_wildnight: {
sites: ['backyard_01'],
owner: 'A',
why: 'A (S17 gate 1, POOL): its separation is PINNED to backyard_01\'s site data. Fly it '
+ 'elsewhere and the pin describes a night nobody plays.',
},
storm_02b_icenight: {
sites: ['backyard_01'],
owner: 'A',
why: 'A (S17 gate 1, POOL), on A\'s S13 measurement: `gardenBeyondSaving` is a fact about '
+ 'THAT BED under that ice, not a property of the storm. Offered over another yard the '
+ 'night would carry the excuse without the evidence.',
},
});
/**
* May this storm be offered over this yard?
* @returns {string|null} the refusal in English, or null when the pairing is offerable
*/
export function pairingRefusal(stormKey, siteKey) {
const law = PAIRING_LAW[stormKey];
if (!law || !law.sites) return null;
if (law.sites.includes(siteKey)) return null;
return `${stormKey} over ${siteKey}: not an offerable pairing — ${law.why} `
+ `(offerable: ${law.sites.join(', ')}; ruled by ${law.owner})`;
}
/**
* Every pairing refusal in a list of candidate nights, as data.
*
* Deliberately NOT a filter. Silently dropping a bad entry leaves a morning
* with one offer and no explanation a runtime surprise wearing a fix's
* clothes. The caller (c.test today; a `board.js` load-time assert the day A
* wants one) decides how loud to be, and gets the measurement in the message.
*
* @param {Array<{id?:string, storm:string, site:string}>} pairings
* @returns {{id:string, why:string}[]} empty = every pairing is offerable
*/
export function pairingRefusals(pairings = []) {
const out = [];
for (const p of pairings) {
const why = pairingRefusal(p.storm, p.site);
if (why) out.push({ id: p.id ?? `${p.storm}×${p.site}`, why });
}
return out;
}
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
// import.meta.url is right under both, and under whatever Lane A does next.

Binary file not shown.

Binary file not shown.