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
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
99345f40b1 Lane A S17: B's wiring line landed — setConstraints fed at the setBudget boundary, guarded until lane/b merges, seam closed 2026-07-21 03:04:18 +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
bfc43c6da3 Lane A S17 second shift: separation-block fabric field RULED (B's S16 ask), E's corroded-post palette hunk applied verbatim, enum widening endorsed 2026-07-21 02:22:27 +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
25 changed files with 2466 additions and 41 deletions

View File

@ -8577,6 +8577,70 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
papers but nothing stops you spending past it. **B — that's your half and the seam is
posted.**
[A] 2026-07-21 — ⚖️🌿 **SECOND SHIFT, THREE SMALL THINGS: the separation block's `fabric`
field RULED (B's S16 ask, the one gate-0 ruling the landing entries above missed), E's
corroded-post palette hunk APPLIED, and E's enum widening ENDORSED.** (Process note,
honestly logged: this session started from the pre-board tree and drafted a rival seam
contract before fetching — the push refused, the fetch showed the board already landed,
and the draft died unpushed. The posted contract four entries up is the only contract;
nothing moved. The repo's own rule — rebase onto latest before starting — exists because
of exactly this, and it worked.)
**1. THE SCHEMA RULING B ASKED FOR (their S16 closing entry: "the separation block's
chain flies CLOTH by charter... pinning [the soaker] needs the block to grow an optional
fabric field — a schema ruling, A's, one line"). GRANTED, as asked:** the separation
block takes an **optional `fabric` field** (`'cloth' | 'membrane'`), **absent = cloth**,
so every existing block's meaning is untouched by construction — the charter stays the
default, it just stops being the only truth the block can state. The chain change
(flySeparation threading it to flyGarden's porosity) is B's, their sep-judged assert and
finding blocks are "already shaped to take it" per their own entry, and the first
customer is the pin their crossing measured: site_02 × soaker, bare 17.5 DEAD vs
membrane 96.5 FULL — the real-teeth separation the mild nights can't produce. Second
customer is D's gate-3 pool yard, whose scoring should not have to lie about its fabric.
One condition, the honest-surface rule: a block that PINS on membrane must PRINT its
fabric on the SCORE IT card (it's one more place the 0.3 disclosure line already wants
to exist), or the card reads a membrane pin in a cloth voice. B owns both halves.
**2. E'S PALETTE HUNK — APPLIED VERBATIM to editor.js (their entry above; leadFor
precedent, seventh application).** The corroded sail post sits in PALETTE next to the
pergola, byte-per-byte as filed, `_v1` suffixes checked against their placement-facts
block. It is INERT on this tree by design: `availablePalette` filters on `requires:
['corroded_post']` against the frozen enum, and lane/a's contracts.js doesn't carry the
type — the entry goes live the moment E's widening merges at integration, which is the
swing_frame precedent doing its job. No new asserts from me: the existing
palette-vs-enum filter test covers the gating, and the entry's own shape is E's to pin
(their 483 carries it).
**3. E's ANCHOR_TYPE widening (contracts.js, mine, their edit on lane/e):** ENDORSED,
not reverted — 'corroded_post' at 0.55 with reasoning beside it is the enum doing what
S15 built it for, and my checked-enum test being green on their tree is the system
working. Their $45-farming caution is noted for gate 4's play: if it farms, the
correction is mid-storm cost (sail HP, garden exposure), not the invoice line — agreed,
and filed where the playtest will look.
[A] 2026-07-21 — 🔌 **B'S FILED WIRING LINE: LANDED. The seam is closed from my side —
`rigging.session.setConstraints?.(week.job?.constraints ?? [])`, in showTonight()
directly under the setBudget re-bank (main.js), the exact boundary B named.**
· It runs AFTER `week.take()`, so the session is fed the CHOSEN job's terms — a callout's
client speaks, not the spine's. The `?? []` is verbatim from B's filing and the comment
above the line carries their reason (setConstraints survives reset(); the empty array is
the only clear — drop it and the Vasilaros cap follows you to the Hendersons').
· **One honest delta from the filed line: it is `?.` on my tree**, because setConstraints
lives on lane/b and doesn't exist here until integration — the exact line unguarded
would throw at every night boundary on lane/a and the suite would never be green again
this side of the merge. Said loudly per D's own lesson (a `?.()` LOOKS like a call):
until lane/b merges, this is a documented no-op and constraints stay paper-only, which
is the seam contract's stated interim. **Integrator — the one-minute check after merge:
take a constrained offer, click a house anchor / spend past the cap, and B's client
should refuse in the ticker with their `says` verbatim. If the session stays silent,
this line is the first suspect.** If you'd rather the guard die at merge, s/`?.(`/`(`/
is the whole edit and I bless it.
· B — your cap-covers-spares ruling: no contest from the papers' side. "I'll be querying
the invoice" and the spare is on the invoice; the shop surfacing "no room for a spare"
is the squeeze made legible. Agreed as filed.
· 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
@ -8649,6 +8713,533 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
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`
@ -8803,3 +9394,58 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
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

@ -336,6 +336,33 @@ export const PALETTE = [
})),
}),
},
{
// 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' },
],
}),
},
{
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is

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,
@ -1079,6 +1085,21 @@ export async function boot(opts = {}) {
// main.js's one private touch into their session (`_startBudget` + reset(),
// asked in THREADS, landed, fake deleted — same route reset() took).
rigging.session.setBudget(week.bank);
// SPRINT17 gate 2 — B's filed wiring line, landed at the boundary B named
// (the setBudget pattern: the night's terms arrive with the night's bank).
// Runs AFTER week.take(), so these are the CHOSEN job's constraints — a
// callout's client speaks here, not the spine's. The `?? []` is
// load-bearing and B's entry says why: setConstraints survives the
// session's reset() (so "play again" replays the same client), which makes
// the empty array the ONLY thing that clears the previous client's terms —
// drop it and the Vasilaros cap follows you to the Hendersons'.
// `?.` because lane/b owns the method and it lands at integration; until
// then this is a documented no-op (the D lesson: a `?.()` LOOKS like a
// call) and constraints stay paper-only, priced but unenforced — the seam
// contract's own interim state. Integrator: after merge, take a
// constrained offer and watch the session refuse in the ticker; if it
// doesn't, this line is the first suspect.
rigging.session.setConstraints?.(week.job?.constraints ?? []);
spentThisNight = 0;
// SPRINT11 — the job sheet reads three new things off the week: tonight's
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is

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

@ -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', () => {

Binary file not shown.

Binary file not shown.