Commit Graph

220 Commits

Author SHA1 Message Date
type-two
4c1086c805 Wind authoring for the yard editor: venturi gizmo (axis as a LINE, mod pi), tree-shelter volumes, wind-field overlay
Gate 2.2. Mounts into Lane A's editor through the published seams only —
panel at reserved order 50, every gizmo parented to EDITOR.overlay, venturi
drag through registerTool, site.wind mutated then markDirty(). No second wind
validator (validateSiteWind is already inside A's validateSite) and no private
harness: windForSite() is the only wind this file builds.

Gizmos are drawn from wind.core.venturi/shelters — what the wind actually
flies — not from the site JSON, so a mis-wired setVenturi shows up as a funnel
that vanishes rather than a number nobody reads.
2026-07-18 16:06:16 +10:00
type-two
b9b64604b3 Palette audit + two temptation props: honest extras, in the factory
SPRINT14 gate 3.1. The editor is about to offer these GLBs to an author by
name, which turns every silent field in them into a trap for whoever places
one. Audited the placeable set, fixed the gaps in the factory (nothing
hand-edited), and added the two props D needs to author a yard worth playing.

THE AUDIT, and what it found:

- Four `*_anchor` nodes carried no rating_hint at all — door_anchor,
  pickup_anchor, grip_anchor, window_light_anchor. adoptAnchor does
  `rating_hint ?? 1`, so silence there does not mean "unrated", it means
  RATED PERFECT: a shed door skin outranking a concreted post, conjured out
  of a missing field. They now deny it explicitly (`tie_off: false`).
- The carport's price resolved only by coincidence: collateralFor() matches a
  STRUCTURE whose site id equals the anchors' collateral string, and site_02
  happens to name its structure "carport". The editor will generate
  "carport_2" for the second one, and the trap would have gone free — the
  gutter bug reborn in the sprint meant to bury it. The GLB now carries
  `collateral_key`, like the house does for "gutter". Lane A: the runtime
  half is yours (collateralFor could fall back to it).
- The trampoline is unpriced, and now says so rather than omitting it.

Three new rules make the audit permanent, and each is proven able to fail:
no silent anchors, no collateral string nobody prices, no baked anchor_type
outside the checked ANCHOR_TYPE enum. Rule 2 found the carport gap on its
first run — an unfaked negative control.

THE PROPS:

- swing_set_01 (+ wrecked) — the palette's honest middle. It had a ceiling
  (gum fork 1.0) and a trap (carport beam 0.22) and nothing in between. Two
  apex anchors at 0.45, typed `swing_frame` (NOT `post`: the type string is
  what the player reads before committing, and "post" promises concrete),
  and a crossbar that is the most anchor-looking object in the game and is
  not one. Priced at $140 — proposal, reasoning baked beside it, A rules the
  number: gnome 25 < gutter 90 < swing 140 < carport 180.
- tree_jacaranda_01 — a second species whose LADDER is the feature.
  Gum: 1.00/0.88/0.76, climb freely, pay 24%.
  Jacaranda: 0.95/0.52/0.40, climb at all, pay 58%.
  That makes tree choice a real decision instead of "is there a tree".
  Unpriced by ruling, same as the bike: no limb-failure event exists to see.

Both wreck/orientation claims are measured in browser coords, not reasoned —
`wreck_falls_toward: "+Z"` is baked AND asserted against the mesh.

Selftest 376/0/0. Two consecutive full factory runs: 44 files byte-identical.
2026-07-18 16:01:27 +10:00
type-two
6825794d98 Lane B S14 gate 2.1: SCORE IT — shared scorecard engine + editor panel
scorecard.js: the audit run-logic that audit.html had grown moves into one
shared engine both front-ends import (build the scoring world, sweep, fly the
winners, pick the best line, judge the separation block). Takes a site OBJECT,
not a path — loadSite always returned an object, so the fetch was never the
audit's business. Builds its OWN dressed world on a re-pointable wind proxy so
the editor's calm stub can never reach a score; every m/s still comes from
windForSite() via sweep.js/gardenfly.js.

editor_score.js: front-end only, zero audit logic. Mounts at A's reserved
order 40, renders the card with A's contract classes, says the funnel state
first and loudly, and marks itself stale when the yard changes underneath it.
2026-07-18 15:56:50 +10:00
type-two
16dee119d6 Sprint 14 gate 1: the yard editor core + two shipped bugs it found
web/world/editor.html + js/editor.js. Loads any existing site or an empty
template and renders the REAL dressed world through the same
loadSite/createWorld/dress path the game boots -- not a diagram, and no
second renderer to keep in sync. Place/drag/delete on the ground plane by
raycast, writing the SAME named-key site-JSON shapes world.js already reads
(the gnome pattern; there is deliberately no generic props[] and the editor
does not invent one). Live validateSite into a panel, canonical
stable-key-order export with a loud _INVALID key, visible _design/_why
authoring fields, and an export panel that says export != shipped.

Published the DOM seam contract in editor.html's header so B's SCORE IT and
C's wind authoring can start against it immediately -- mountPanel + a class
kit, the way E's .letterhead kit worked in Sprint 12.

Two rendering bugs in world.js that the editor surfaced in its first ten
minutes, both verified by looking at the yard rather than by reasoning:

- the graybox house was built unconditionally and dress() only retires it
  when a house GLB loads, so site_02_corner_block -- which declares no house
  -- has been shipping a 16x3x6 m grey slab across night 3's whole north
  horizon, in solids, on the public URL.
- SHED alone was bound raw while SHED_TABLE/GNOME/BIKE all get their degrees
  converted, so SHED.rotY was undefined, the shed's quaternion was all NaN,
  and three.js silently declines to draw a NaN world matrix. The Hendersons'
  shed has never rendered; ladder.js has been leaning the ladder on thin air.

Also: createWorld threw on a site with no shedTable (unreachable for both
shipped sites, reached by the editor template on its first frame). Now null,
which every consumer already guards and which CONTRACT.world permits.

Selftest 362/0/0. Asserts for the two fixes land with the round-trip test in
the next commit -- pushing now because B and C are blocked on the seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:46:01 +10:00
type-two
edb3377818 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-18 14:26:16 +10:00
type-two
646efa7244 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-18 14:26:16 +10:00
type-two
68edc607e7 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-18 14:26:16 +10:00
type-two
cc9849d538 Sprint 13 gate 1.4: the played run is the ruling's centre — canon written, pin labeled sim-chain, THREADS
- Played night 4 on the pinned line through the real UI (D's protocol):
  hp 55.7 WIN, 2/4 lost, pyrrhic verdict — the ~8 gap to the sim chain is the
  t=38 crate + ponding landing on p2's 4% margin. Ruled deliberate: the wild
  night sells no clean line; the pyrrhic win is the intended reading.
- Site block records _playedReference; a.test pin re-labeled as the
  deterministic sim-chain tripwire whose margin is what keeps the PLAYED
  night over the win line. Band 18-45 ruled an availability floor (B's 1c).
- THREADS: full second ruling, night-5 canon, Sprint-14 notes.
2026-07-18 14:18:37 +10:00
type-two
80b53e9547 Sprint 13 gate 1.4: NIGHT 5 RULED — the garden is beyond saving BY DESIGN, and the verdict teaches it
- week.js night 5 carries gardenBeyondSaving: true with the measured case
  (bare 0.0, best buyable 27.7 vs WIN>=50 — B's game-true table, my probe
  agreeing) and the arc argument; nightAt defaults the flag FALSE so new
  nights can never inherit the excuse. Brief levels with the player up front.
- verdictFor honours it on a LOSS only: no more 'the hail fell where your sail
  wasn't' on a night where no placement wins; a 4/4 hold is credited as the
  whole job, a torn sail is still named and owned. A win ignores the flag (a
  stale flag must never eat a real win). Asserted both ways + mutation notes.
- GARDEN_DRAIN untouched, per the standing ruling; icenight hail budget also
  untouched — the loss is the design, so the surfaces now say so.
2026-07-18 14:11:58 +10:00
type-two
1c0fcc757b Sprint 13 gate 1.4 RE-RULED on B's skew finding: the pin measures the game now, and the target is restated
- a.test's separation flight had the phantom 12 s settle B caught (SailRig
  samples wind at its INTERNAL clock; the storm flew 12 s off the authored
  curve). Settle deleted — commit->attach->storm from t=0, C's corrected bench
  pattern; venturi wired from site data so the flight can never assume a yard.
- Game-true the line reads 63.6, not 68.3 — FULL (>66) was met by the harness,
  not the game, and the re-sweep says nothing buyable reaches 66 (the twin-post
  covering quad tears at 6.9 kN). Target RESTATED to the game's real stakes,
  with headroom: held >60 and 0/4 (win line 50; measured 63.6), bare <50
  (35.7), separation >=25 (27.9). Field names kept — B's gardenfly reads them.
- DESIGN CANON in the site block: the wild night's garden is WON SCARRED,
  never FULL; the week's arc is full/full/full, won-tattered, beyond-saving.
- p5 _why table corrected to game-true numbers (first sweep ran ~4 HP hot).
2026-07-18 14:11:58 +10:00
type-two
e1e14018bf Lane B S13: THE AUDIT REWRITE — the audit predicts the garden, on C's corrected picture
The scoring quantity is the FLOWN garden state now, not static cover%.
New gardenfly.js: windForSite (C's shared builder — venturi + shelters, one
copy in the repo) → real attach (throws on D's skipped-attach trap) → skyfx
exposure → garden.js, per candidate line; cover% demoted to a labelled
geometry diagnostic. audit.html flies every affordable line + bare bed and
judges the site's pinned separation block on the block's own storm; audit.mjs
(node) keeps fast winnability, gains p5 in its verified dump, and points at
the browser for garden truth.

On C's correction, three structural adoptions:
- windForSite everywhere (three harnesses independently mis-built site wind;
  no fourth copy);
- LIVE anchors via the re-pointable wind proxy — the audit's own frozen-sway
  remap was C's landmine 2 wearing my file's name (audit.html:69);
- the MARGIN rule (AUDIT.MARGIN = 0.15, one copy): every row prices twice —
  $hw holds vs $cleanHw with >=15% headroom — and only clean lines are
  winners; verdict code 'marginal-only' when the budget can only buy the
  knife edge (D's 39.8-TATTERED wild night, C's dead 91.9 headline).

Also game-true flight: the phantom 12s calm settle is GONE from the sweep
(commit->attach->storm is one keypress; the settle skewed every storm sample
12 s off the authored curve — SailRig samples wind at its INTERNAL clock).
That skew is not academic: it is a.test's separation-flight harness too, and
gardenfly.selftest now pins BOTH chains (game-true 63.8 tattered vs skewed
68.4 'full' on the pinned p5 line — the disagreement is flagged to A in
THREADS, not overruled here). The pinned recipe also sweeps regardless of the
18-45 band (it is 45.9 m² — the band predates p5; flagged to A).

Selftest 356/0/0 on the scratch merge (b+a+c@45bdc2d). Mutation-checked:
MARGIN=0 reddens the margin test (node); dropping siteDef from gardenfly's
windForSite call reddens the venturi test (browser, exact test named).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:50:15 +10:00
type-two
25eefccc69 Lane B S13: kill the marked fake — garden model comes from A's garden.js everywhere
garden_probe.html drops its TEMPORARY COPY of HAIL_WEIGHT/RAIN_WEIGHT/
GARDEN_DRAIN and flies main.js's own createGarden (imported from garden.js),
so the probe's hp/state/damage-split are the model's, not a re-derivation.
balance.test.js's local GARDEN_DRAIN/W_HAIL/W_RAIN copy re-points at the
garden.js exports (A's flag on balance.test.js:38) — a future retune now
moves this suite the day it lands instead of drifting silently.

Selftest 351/0/0 on the scratch merge (b+a+c) — the constants are identical,
so no number moved; the SOURCE moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:50:15 +10:00
type-two
45bdc2d079 Lane C S13 correction: the bench flew site_02 funnel-OFF and tree-FROZEN; wild-night headline dies
D's live replay caught landmine 1: bench.js read def.wind.venturi off the
STORM def where a venturi never lives (it is SITE data, main.js:453), and the
probes never attempted it — every site_02 bench number was funnel-off, the
third harness to make B's Sprint-11 mistake. Verifying the fix exposed
landmine 2: the tools remapped anchors to a static sway:()=>pos, freezing the
gum tree whose sway is the dynamic load a tr1 rig eats (ablation on the $80
line's q4: 1.02 funnel-off/static, 1.03 funnel-on/static, 1.24 funnel-on/real
anchors). Backyard numbers matched D's UI to the decimal precisely because
p1..p4 are posts — both landmines are no-ops there.

Fix: windForSite(stormDef, siteDef, anchors) in weather.js is the one shared
wind builder (venturi + shelters, exactly main.js's wiring); all garden_bench
tools build wind through it, fly world.anchors themselves via a re-pointable
wind proxy, print the venturi in their headers, and drop the phantom 12 s
settle (commit->attach->storm is one keypress, and the settle skewed storm
sampling via SailRig's internal clock). c.test.js pins the helper with B's
strictly-raises assert against the real shipped funnel (mutation-checked).

Corrected numbers: $80 line wildnight 91.9 with q4 at 1.24-vs-1.2 rating - a
knife edge the real game falls off (D UI: q3+q4 break, 39.8 TATTERED; D's UI
is canonical there, and a corner over its rating is not a safe line either
way). Buster verdict stands (95.2 bench / 97.9 D live). Icenight 9 DEAD 2/4.
Bench still under-reads UI peaks ~5-15% on site_02 - residual in the pool,
stated in THREADS. Separation-target site_02 example withdrawn to A.

Selftest 341/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:08:21 +10:00
type-two
482803a480 Sprint 13 gate 1 RULINGS: p5 the clothesline post; the separation target, amended and PINNED
- backyard_01 gains p5 (5.5,-2) h2.6, honest steel NE of the bed: before it the
  yard could not beat tattered on the wild night at any price (covering quads
  need a fascia corner capped at 2.27 kN; the holding line ceilings at 61.3).
  Swept against the $80 wallet; position/height table in the site JSON.
- Separation target adopted AMENDED (ruled on D's funnel-corrected picture):
  wild night, best buyable line FULL (>66) and WIN; bare bed LOSES (<50).
  Pinned as site data (backyard_01.separation names the $75 recipe
  p5=rated + p1/p2/p3=shackle) and FLOWN by a.test through the real chain:
  shop at START_BUDGET -> commit -> attach -> skyfx -> garden.js. Measured
  68.3 FULL / 0 lost vs bare 35.7 (sep 32.6).
- Full cover stays unbuyable (covering quads still pull 6.4-6.9 kN), the
  >45 m2 floor holds, and the ice night still refuses $80 (needs $105 -
  p1/p2 miss their shackles by 0.05 kN) - night 5 stays bank-funded canon.
- New asserts: M reaches the real bus / is only advertised when real;
  anchor census now 13 (5 posts). Selftest 341/0/0.
2026-07-18 12:44:54 +10:00
type-two
7acee5437b Sprint 13: garden model exported as garden.js (B's gate-1.2 blocker) + applyMute(sky,on) as a value
- createGarden / GARDEN_DRAIN / HAIL_WEIGHT / RAIN_WEIGHT move from main.js
  module-locals to garden.js, unchanged — the audit must predict the sim off
  the sim's OWN code, not a drifting copy. GARDEN_DRAIN's comment now carries
  the gate-1.4 ruling: the constant does not move; it owns the bare-bed arc.
- M-mute application extracted to exported applyMute(sky, on): calls C's
  sky.setMute when the tap exists, reports honestly when it does not, so the
  selftest can fail the wiring instead of trusting a ?.() no-op.
2026-07-18 12:44:37 +10:00
type-two
b70699189c Lane C S13 gate 2.3 + D's sliver: ambient leaves from 30 km/h; hail pool hides at zero
Leaves: a pooled handful (cap 7) streaming with the wind from 8.3 m/s — the
same number D keys the player's lean on, so the yard and the body start
telling one story at one speed. Count rides a 2.5 s speed EMA so it doesn't
strobe; own seeded rng (seed ^ 0x1eaf) so the pool never shifts the crate
sequence; bit-identical under identical (dt, t) streams (asserted).
debris.clear() rewinds the layer. dev_skyfx flies the debris layer now and
warms it to the scrub point, so ?t=50 shows the leaves t=50 would have.

D's aftermath sliver, exactly as diagnosed on lane/d: the hail InstancedMesh
at count:0 with frustum culling and depthWrite off still draws instance 0's
identity matrix — a 5 cm always-on-top speck at the origin. mesh.visible now
gates on n > 0, sky.hail joins the public fx object, and a whole-storm assert
demands hidden-at-zero / shown-when-falling in both directions (gated on
floor(1300 x intensity) >= 1: a burst's first frames honestly draw nothing).

THREADS: gate-2 wrap entry — D's judging unblocked, A's M key live.
Selftest 340/0/0 (+2 tests). Mutation-checked: dropped stepLeaves, a 3 m/s
threshold, and an always-visible hail pool all go red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:51:26 +10:00
type-two
c2a19df7d5 Lane C S13 gate 2.2: the front wall grounds on the horizon, ends feathered
Two QA sightings from in-yard eye height, both geometry, both reproduced on
dev_skyfx before touching anything. (1) The band stopped AT the camera's
horizontal plane, but the ground's true horizon sits ~0.6 deg below it (the
dip), so a bright sky sliver showed under the base — the floating slab. The
band now overshoots to ~12 deg below the equator; the ground occludes the
overshoot by depth, and it survives scale.y's 0.3 rise floor. (2) The arc-end
fade (sin^0.75) left the outer tenth at ~0.36 alpha — the hard vertical seam.
Now sin^1.6 with the arc widened 2.2->2.6 rad so the solid core keeps its
span while the ends drop under 0.12.

Verified by eye on dev_skyfx (southerly t=24, front 0.51): base meets the
ground line, no sliver, no seam. Selftest 338/0/0 (+1 test pinning both in
numbers). Mutation-checked: the old thetaLength and the old exponent each red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:30:48 +10:00
type-two
a861151a4a Lane C S13 gate 2.1: the storm grade — the night finally looks like a night
QA played the southerly at 65 km/h under a noon-blue sky with razor midday
shadows. The arithmetic said why: sky, sun and hemi all multiplied the weather
by the author's palette (darkness 0.5 on the southerly), so the dial could
zero the weather's say — 0.69 storminess became a 0.35 nudge. The grade floors
it (storminess x lerp(0.5, 1, darkness)): a full gale now goes at least half
way to slate on any palette while the wild night keeps its authored black.
Plus the two things nothing keyed at all: the sun disc lerps toward slate and
sun.shadow.radius lerps 1->7 on storminess (PCFSoft, so it is real blur).
Deterministic — pure in t, same curve the rain uses, exposed as sky.stormGrade
above the render gate for headless reads. dispose() hands back intensity,
colour and radius exactly.

Verified by eye in dev_skyfx: southerly t=55, 18.5 m/s reads slate weather.
Selftest 337/0/0 (+1 test). Mutation-checked: the old formula and a dropped
radius line both go red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:21:53 +10:00
type-two
8a3dc323bc Lane C S13: sky.setMute(on) — the tap A's M key was waiting on
One flag plus a gain write, but the flag is the point: a mute pressed on the
splash screen, before the first gesture builds the audio graph, is remembered
and honoured by unlock(). main.js already feature-detects sky.setMute, so the
M key lights itself up on this landing. audio.masterGain getter added so the
assert reads the real bus, not the flag back.

Selftest 336/0/0 (was 335, +1 test). Mutation-checked both ways: a no-op
setMute and an unlock that forgets a pre-unlock mute both go red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:11:39 +10:00
type-two
7758b65331 Lane D: the player leans into the wind (gate 2.4 — as a root pose, not a clip)
The QA pass's biggest player finding: 'stands bolt upright and unbothered'
at 65 km/h. E rendered the two Mixamo lean candidates and rejected both on
screen, and proved the deeper reason a clip can't do this: _rotOnly drops
Hips.quaternion from every clip at load, so a baked lean is discarded, and
a clip's lean axis is fixed while the wind's bearing swings. So the lean is
the ROOT, the same machine as the knockdown pitch and climbY lift.

sim (player.sim.js): sim.lean 0..1 + sim.leanDir, eased over leanSecs.
Magnitude off windBase (the ~4s EMA), NOT raw windSpeed — measured: raw
strobes the posture 8-18x/min across the storms, windBase settles it to 0-2
changes per 90s. Sustained wind = posture; the gust is already the
stumble/knock event. Suppressed on your back (pitch owns the body), braced
(TakeCover is its own hunch), and up a ladder. Bands land on the storm
rating ladder: night 1 upright, 2-3 lean, 4-5 in the gale.

view (player.js): a foot-pivot tip toward leanDir, max leanMaxRad (~17deg,
a third of a knockdown's pi/2), composed under the yaw. It's the else branch
of the pitch tip by construction — the sim guarantees lean=0 whenever
pitch>0, so they never fight.

Three asserts: magnitude scales with sustained speed, a lone gust does NOT
snap you over (windBase inertia), and it's downwind + suppressed when
floored/braced/aloft. Mutation-checked: forcing lean to 0 reddens two.
Verified live at 71 km/h — leanDir dot wind = 1.0, the figure visibly
braces. Selftest 340/0/0. This is the root lean E asked me to wire first;
whether their WindPosture additive follows is a look-at-it-together call.
2026-07-18 01:33:47 +10:00
type-two
701403ab1d Sprint 13 gate 2.5: two camera fixes — spawn framing, aftermath cloth-swallow
SPAWN FRAMING. The QA pass: "a pole dead-centre through the player on every
single first impression." Measured, not eyeballed — player spawns at (0,6)
looking up the yard, and post p3 stands at (0,7), one metre behind, dead on the
view line. spawnYawFor() sweeps outward from the ideal frame (camera opposite
the bed, so the player stands in front of what they protect) and takes the
first yaw that clears every obstacle. Per YARD, off site data: site_02 spawns
elsewhere with its posts elsewhere, so a hand-tuned constant would frame the
corner block by luck.

Two numbers here are measured and both were wrong first. Clearance 0.6, not 1.1
— my first pass asked 1.1m and produced a WORSE frame than the bug, swinging
105° to stare at a fence, because the greatest distance any obstacle can have
from the camera->head line is its own distance from the player, and p3 is only
1m away, so 1.1 was unreachable by construction. maxOff 90° — a frame turned
more than a quarter-circle off the bed is no longer a frame of the bed, so the
"roomiest" fallback is bounded to the arc. The assert reproduces the 105° from
the real yard and pins that the cap brings it to 68°.

AFTERMATH CLOTH-SWALLOW. "The dead draped sail can swallow the camera whole."
The camera has collided with the house since Sprint 2; the sail was just never
in the list. While up it hangs above head height and nothing notices — but a
FAILED sail lies in the yard at head height, the one moment you most want to
look at it. refreshCameraSolids() adds the cloth to the set, called from both
rebuilds (loadSiteInto makes new world.solids, rigSail makes a new cloth; either
alone strands the camera on a disposed mesh). Its bounding sphere recomputes
every frame, so the raycast reads live geometry. Verified live: camera solids
9 -> 10 the moment a sail rigs, and the cloth is in the set.

Added camera.solids getter so that was checkable at all — "did the sail join
the set" was otherwise a claim with no seam. And hoisted sailView's declaration
with rig/rigging: loadSiteInto's boot-time call reads it before any cloth
exists, which is a TDZ throw (page goes blank) that the same block already
documents for rig/rigging.

selftest 339/0/0 (was 338).
2026-07-18 01:18:29 +10:00
type-two
05337c2989 Lane B S13: consumable spares — close D's free-infinite-spares hole (count half)
D measured it live: interact.js gated the shed-table spare on !player.carrying
alone, never reading the count, so a player who bought zero walked off with
unlimited free shackles mid-storm — the spare economy the ladder cost is
balanced against, free on the spare side, on the public deploy.

My half of the cross-lane seam: RiggingSession gains sparesRemaining (getter)
and takeSpare(). `spares` is the shed-table count, so takeSpare() decrements it
— which also fixes a second bug for free: main.js:720 refunds
session.spares * SPARE_COST as "a spare you never had to use", so you were being
refunded for spares you'd already spent. Decrementing makes that honest with no
change to main.js.

Wiring stays A's (thread the session into wireYardActions) and the gate+consume
stays D's (two lines, posted in THREADS, D offered). Asserted: take-without-buy
fails, table runs dry, consumed spare no longer refundable.

rigging.selftest 21/21.
2026-07-18 01:09:51 +10:00
type-two
7048ad6482 Lane B S13: gate-3 smalls — rig.t was a determinism bug, + prep readouts
Four of the QA/D "smalls", one of which was not small.

rig.t never reset between nights: main.js builds one SailRig at boot and
re-attach()es it each night while step() runs in every phase, so the clock ran
on and night 2 sampled its storm from wherever night 1's aftermath left off —
30-50% weaker, past the end of the curve, and offset by however long the invoice
was on screen. A determinism break. Fixed in attach() (zeroes t and _acc);
asserted as "two identical nights, one rig -> byte-equal traces", mutation-checked.

standing-kN preview (D's "$60 blind"): the prep panel shows each corner's
still-air load vs its effective rating, and a summary line, so the tension dial's
static cost is visible before you pay. At 1.4, p4 reads 1.62/1.2 kN !! RIPS AT
REST. session.standingLoads() measures on unbreakable hardware (measuring load,
not rehearsing failure). Cross-checks D's in-play 46m2 number (1.41 kN) exactly.

Panel honesty (D): prep list prints effective rating with the hint shown as its
discount (cb1 0.12/0.3 kN×0.22) — the weak-link arrow now explains itself.

setHardware() throws on non-shop hardware (incl. lookalikes — the shop compares
by identity) instead of silently no-opping; player-reachable failures still
return {ok:false}. balance.test documents the old silent-fail bite.

BLOWN label collision (hud.js — A's file, flagged in THREADS): screen-space
de-collision stacks overlapping corner bars. Verified by projecting cb1/cb2's
real positions through a swept camera — 6/72 viewpoints collide (all ~12m, where
the QA played), thresholds catch exactly those.

Gate 1.2 and the fuse-line stretch stay held on C's geometry fix + A's garden
export. Withdrew my night-1 flag (the card says it's the tutorial night by
design); the ice-night win-line question stands.

selftest 339/0/0 (335 + 4 new).
2026-07-18 01:04:02 +10:00
type-two
d7658fb7af Lane D: ladder survives a mid-carry knockdown, and the prompt names the right bracket
SPRINT13 gate 2.6 pool, both found by the integrator's QA and both silent
(no error, no red, just a mechanic that stopped):

- The ladder left the game if the wind took you over while carrying it.
  knockdown() -> drop() nulls player.carrying but nothing told the ladder,
  so state.carried stayed true: the mesh hid (visible = !carried), pos()
  returned null so there was nothing to walk to, and canUse gated on
  !carried so it could never be picked up again. Swept the whole yard post-
  knockdown: no usable ladder offer anywhere, while ladder_place still said
  'it's by the shed'. On the corner block that silently ends the site's
  thesis (cb1/cb2 are bracket work). Fix: reconcile against the player's
  hands every frame in update() and let the ladder fall flat where it was
  lost, findable and re-takeable. Verified in the running game.

- The place label hardcoded 'the fascia' — a lie on a carport beam. Now
  keyed on anchor.type (a checked enum since Sprint 11): house -> the
  fascia, carport -> the carport bracket, unknown -> the bracket (vague but
  never false). And it stops claiming 'by the shed' once the ladder is
  dropped in the yard.

Two new asserts against the REAL createLadder, not the fake: the drop-
reconcile and the label. Mutation-checked — reverting the reconcile turns
both red on the right assertions. Selftest 337/0/0 (335 + 2).
2026-07-18 01:00:01 +10:00
type-two
6e5f4b5222 Sprint 13 gate 3: the front door — splash, pause, touch notice, Enter pinned
The URL is public, so this is the first thing a stranger meets.

ENTER, PINNED. The exploit shipped because the rule lived in a keydown closure
inside boot(), behind a canvas and a WebGL context — nowhere a test could
reach. That's WHY it went out: not carelessness, no seam. `enterCommits(phase,
cardOpen)` is a value now, asserted exhaustively over PHASES so a sixth phase
is covered the day it appears, and the safe default (may not advance) is the
one it gets. Mutation-checked: reverting the guard reddens forecast, storm and
aftermath.

SPLASH. A cold visitor used to land on a job sheet — an invoice-shaped card
from a business they'd never heard of, quoting money for a job nobody had
explained, controls revealed later on prep's help line. It's a good SECOND
card. One more .card, not a menu system: there is nothing to configure. Built
on E's letterhead because the masthead IS the game's name. boot({splash:false})
for benches and D's harness.

P PAUSE — and the test is the story. The pause lives in frame(), frame() is
only called by rAF, and rAF DOES NOT FIRE IN A HIDDEN TAB. My first probe drove
the real loop and reported success: simT advanced 0 while paused... and 0 while
running. It was measuring a frozen tab, and it would have passed no matter what
I wrote. So the rule is a value: accumulate(acc, raw, paused). Paused DRAINS
the accumulator rather than carrying it — a kept `acc` spends time that elapsed
while you were paused on the first frame after resume, which is a free sixtieth
of a second of storm and exactly how two runs of a deterministic sim come to
disagree. Storm-only, and cleared at every phase change: a pause flag left set
where P is inert would be a soft-lock built out of a comfort feature.

M MUTE — wired, honest, and not advertised yet. C's bus has no tap: createAudio
holds `master` inside skyfx's closure and exposes only unlockAudio(). Asked in
THREADS. `sky.setMute?.(on)` behind a key the splash promises would be D's
night-3 soft-lock with a keycap on it — `rigging.setWorld?.(world)` sat in this
same file for a sprint doing nothing, silently, because ?.() on a missing method
looks exactly like a call. So the UI asks before it advertises, and M lights up
by itself the day C lands the tap.

TOUCH NOTICE. The naive check is the bug: `(pointer: coarse)` asks what the
PRIMARY pointer is, so a touchscreen laptop answers coarse and gets locked out
of a game it can play — worse than the dead canvas it replaces. `(any-pointer:
fine)` asks whether a mouse exists at all. Detection errs toward letting people
in; unknown browsers get the game, not a lecture.

DEV LINE. Out from under B's anchor panel (both were top-left, typesetting on
each other), and off unless localhost or ?dev=1 — a stranger on partly.party was
reading "60 fps · storm 12.3s · debris 4" on the front door.

selftest 338/0/0 (was 335).
2026-07-17 23:59:27 +10:00
type-two
5859bc83fa QA pass: fix Enter storm-skip (deployed), log findings; Sprint 13 plan + prompts 2026-07-17 23:26:47 +10:00
type-two
fee687d550 Retitle the game page: HARD YARDS (release name; SHADES stays the repo name) 2026-07-17 21:37:23 +10:00
type-two
688e821fcb Sprint 12 integration: merge all lanes (335/0/0), skyfx effective-rating creak, [I] entry 2026-07-17 21:17:45 +10:00
type-two
28ef4ef138 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
3df0d0c5d0 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
aff0020148 Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
38b6fe9c87 Rule the gutter $90 (E's proposal adopted); teach collateralFor/wreckStructure the house; action B's effective-rating flag
Gate 3.3: collateralFor('gutter') returns {90, 'the gutter'} — canonical in
backyard_01.json beside the reasoning, GLB extras as fallback, per E's
collateralKey chain. House wreck wired on the carport's swap contract
(no-op until lane/e's GLB merges; window_glow state copied). site_02 stays
null: no house, no gutter. hud.js bars + main.js verdict now read
rating × ratingHint, matching sail.js's failure line (B's flag).
selftest 319/0/0.
2026-07-17 20:45:49 +10:00
type-two
807de80969 Wire the kid's bike: site key + loader, gnome pattern; decline the $60
Gate 3.2: bike_kid_01_v1 on backyard_01's south fence, rotYDeg 180 so the
baked -Z lean lands ON the fence — bars measured to the rail (0.286 m
reach, z 9.69 -> grip at 9.98), tyres 0.31 m clear, between posts. Unpriced
by ruling: the sim can't knock a bike over, and billing an unseen event is
the lie the invoice kills; the windward-fence pose is the honest one.
a.test pins lean direction, standoff band, and the ruling. 318/0/0.
2026-07-17 20:34:47 +10:00
type-two
3d89ce8fe8 Paste E's letterhead/invoice kit into hud.js; rule HARD YARDS + sequential ABN
Gate 3.1: the CUT block from tools/jobsheet verbatim — letterhead, billto,
brief, sched, .void, AMOUNT DUE, terms. Docket numbers shared across the
pair (JOB/INV 1040+night); week.js nights carry an addr. Cards scroll when
taller than the window (safe center + overflow). Both rulings adopted and
pinned by DOM asserts; selftest 317/0/0.
2026-07-17 20:27:18 +10:00
type-two
e5c0e3ffee skyfx: the change announces itself — a front wall in the sky (gate 3.4)
Every windchange event gets an in-world tell: a dark bank standing on the
horizon in the quarter the new wind comes FROM, rising across the 12 s
before the change and clearing after the swing, plus a low distant rumble
layer in the audio. Soft-edged via vertex alpha (the hard band read as a
floating rectangle on the bench). Deterministic off (dt, t): progress is a
pure function of t, azimuth comes from dirAt at a fixed post-change
instant. Night 2 teaches the tell at ~18 s; night 3 shows the same wall at
t=6, which is 'looks like night 2 and isn't' made visible.

dev_skyfx.html: the skyfx bench — one storm, bare ground, scrubbable
clock, so the tell can be judged by eye without playing to night 3.
Two new asserts in c.test.js (window, monotonic rise, direction, mesh
orientation, changeless storm stays clear, bit-exact determinism).
Selftest 322/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:24:51 +10:00
type-two
b7f065cd57 e.test: pin the gutter price band, the DOWN/UP swap truth, and anchor absence in the wreck
Five new asserts (Lane E 67 -> 72; selftest 315 -> 320):
- price exists, sits between gnome and carport, keyed to the anchors'
  collateral string, wreck carries the same bill and names its twin
- the torn gutter is DOWN in the wreck and UP in the intact house — negative
  control run: rebuilt with the ground run at eave height, selftest went red
  ('gutter_down tops out at y=2.68', 1 failed / 319 passed) while the Blender
  verify PASSed the same bad file (plan position unmoved, no box span changed);
  restored build round-tripped to identical hashes
- wreck height equals house height (a house does not fall when its gutter does)
- no fascia_anchor_* survives into the wreck
- house pair added to the shared ground-plane test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:21:40 +10:00
type-two
4d1a2d7aa9 Gutter priced and wrecked: GUTTER_COLLATERAL=90 baked with reasoning; torn-gutter house wreck, carport pattern
The fascia anchors have said collateral:"gutter" since Sprint 6 and nobody
priced one — collateralFor('gutter') returns null, backyard_01's house is a
free failure. Proposal rides in the asset like the carport's did: 90, between
the gnome (25) and the carport (180), with the band reasoning next to the
constant. A rules the number.

house_yardside_wrecked_v1: same origin/footprint/facade (shared _house_facade
helper — intact house BIN chunk byte-identical to the committed GLB, only the
three new price extras differ), fascia torn through the anchored span, left
run hanging by its last bracket, right run kinked ON the grass, downpipe out
of plumb, offcuts flat on the lawn. No fascia_anchor_* empties survive — you
cannot re-tie to a ripped eave.

collateral_key is new and explicit: unlike the carport, the structure and the
thing you take have different names, so the key names which collateral string
the value prices.

All 33 GLBs byte-identical across two full factory runs; the house pair also
byte-identical between --only and full runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:21:24 +10:00
type-two
9700b40a30 Wire ratingHint into the failure threshold: the ratings are real (A's ruling)
sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint
(read live from the anchor — dress() mutates in place). Consequence asserted,
not the formula: on site_02's carport line (uniform rated shackles, funnel on,
12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension
(t~29 s into the storm, honest posts hold) and at max. In-suite control pins
the attribution: the same rig with every hint forced to 1 holds 4/4, so the
break is the hint, not the load. Mutation-checked: unwiring the line fails both.

rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint
via _effRating, not bare hw.rating — uniform hardware now flags the genuinely
worst steel instead of the first click. Mutation-checked.

site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs
h.rating * hint >= peak, both front-ends carry ratingHint through (browser off
world.anchors, node dump extended with the live-dumped hints), rows print the
hint. sweep.selftest asserts hints reprice without touching peaks and land
over-ceiling corners in unholdable. Mutation-checked.

Selftest 319/0/0 (315 + 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:19:01 +10:00
type-two
8208c80d78 storm_envelope: the wind side of the failure envelope, per anchor (gate 2.4)
Second harness on B's post-ratingHint audit: measures what the STORM
delivers at every dressed anchor — peak speedAt, tPeak, dynamic pressure
(downdraft folded back in), dose, and Pa/hint (the wind-side failure-order
prediction under load > rating x ratingHint). No cloth, no rig, no tension:
independent by construction.

Browser front-end dresses the yard the way the game does (createWorld +
dress) and wires venturi + tree shelters exactly as main.js:446-447 does.
envelope.selftest.js wired into c.test.js, every assert written to fail if
a wiring line is deleted (sweep.selftest's pattern). Selftest 320/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:10:17 +10:00
type-two
e99f029206 Sprint 11 integration: merge all lanes, wire leadFor + setBudget, untrack launch.json; Sprint 12 plan + prompts 2026-07-17 18:08:32 +10:00
type-two
d194594817 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 17:58:25 +10:00
type-two
f696343fbf Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 17:58:14 +10:00
type-two
f4f93bf621 Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-17 17:58:03 +10:00
type-two
a74075cda5 Sprint 11 shepherd: C's venturi correction taken, D's two landmines defused
C AND D BOTH LANDED WHILE I WAS IN GATE 2, AND BOTH CORRECT ME.

VENTURI — reverted to axis 2.1 / gain 1.5. C reached the same physics I did,
independently (|dot| means an axis is a line, mod PI), and then went one step
further and caught my reasoning: -1.08 was never a proposed AXIS. It's
dirAt(40), the southerly's HEADING once the change settles. 2.1 is the GAP'S
ORIENTATION — site geometry, which doesn't move when the wind does. They're
different quantities that sit a hair over PI apart only because the gap was
BUILT along the southerly.

So "measured beats authored" was the wrong rule for this question: I'd have
swapped a geometry fact for a weather fact because they nearly coincide, and
the site's axis would silently mean the wrong thing the day someone authors a
storm from another quarter. "A number gathered from the wrong harness" caught
both of us on the same question from opposite sides in one sprint.

Gain reverted too, and that one I simply jumped: it's a balance lever, B's audit
calls it, and C's standing offer is to drop to 1.35 only if no $80 line survives.
B hasn't run. D then measured the site winnable off honest anchors at 98% — so
the funnel isn't killing the line and the gain stays.

D's LANDMINES, both mine, both in gate 2's path:
 · CALM_STORM loaded only because night one HAPPENED to be storm_01_gentle.
   Gate 2 rewrote every night entry. Drop gentle and windTime() throws every
   non-storm frame on "Cannot read properties of undefined (reading 'duration')"
   — naming neither storms, nor calm, nor the week. Now explicit in the preload,
   and the assert calls stormsToPreload() with a gentle-free ladder, because
   asserting it against the shipped NIGHTS passes either way. That's how it hid.
 · boot({site}) was a documented lie that failed INVERSELY — markers on the
   requested yard, world on the week's. Honoured through the week now (seek to
   that site's night), because a site without its night is half a game: no
   client, no brief, no storm, no bank. Plus opts.bank, so D can boot the corner
   block at the $237 they measured instead of night one's $80 and OFF THE JOB.

RATINGHINT — D is right that nothing reads it, and the naive fix is worse than
the bug: honest posts are built from site JSON, never touch adoptAnchor, so
their ratingHint was undefined — and `load > hw.rating * undefined` is
`load > NaN`, always false. The strongest anchors would have become UNBREAKABLE
while the carport became the only thing that can fail: the trap would look like
it works, for the wrong reason, and read as a successful playtest. Every anchor
carries a real number now (DEFAULT_RATING_HINT), pinned, so the wire is safe
before it's made. The wire itself is sail.js — B's — and the ruling is in THREADS.

selftest 306/0/0 (was 304).
2026-07-17 17:51:30 +10:00
type-two
4095df8b10 Lane B S11: audit the venturi, land setWorld/setBudget, revive the harness
site_audit's headless false-negative is fixed by the browser front-end
(A's loadSite/createWorld landed in main — that was all it waited on), and
fixing it surfaced a worse bug underneath: the sweep built its wind from the
STORM def alone and never called setVenturi, which main.js:424 does at every
site load. The venturi is SITE data, so every site_02 audit ever run — including
the numbers quoted at C in SPRINT10 — was of a corner block with no gap. The
SPRINT6 p1=7.4kN failure inverted: there the tool called a good site unriggable,
here it called a mean site cheap.

- sweep.js: setVenturi on both winds (the gap doesn't switch off for the settle);
  both front-ends pass the site's funnel and print it, so a funnelled run is
  legible as one.
- sweep.selftest.js (new, wired into b.test.js): the auditor gets an auditor.
  Funnel must strictly raise every corner; a funnel-less site must be
  byte-identical. Verified it fails when the setVenturi calls are deleted.
- rigging.js: setWorld(world) + session.setBudget(n)/setAnchors() — A's two asks.
  Markers are rebuilt (the anchor set changes across sites) and disposed; stale
  picks die with the old yard. Deletes A's "one private touch" into this module.
- dev_rigging.html: dead since SPRINT10 (called createWorld without a site, which
  now throws) and nothing noticed, because a dev page has no selftest to go red.
  Fixed, dressed, and given the site switch it should have had (N = next yard).

Verdict for C/A, in THREADS: site_02 PASSES with the funnel on — 64 of 66 lines
affordable, cheapest $20. The funnel kills nothing, so the gain does NOT need
dropping and E's tree stays put. It's a reach problem, not a gain problem: the
throat's radius is 5 m and the bed centre is 6.08 m away, so the gap screams over
the carport at +50% and reaches the garden at +0.0%.

selftest 300/0/0 (296 + 4 new).
2026-07-17 17:42:54 +10:00
type-two
8ec01028f9 Lane E, Sprint 11: client juice — job sheet, invoice, and the kid's bike
The letterhead is the point: one business, two documents, same masthead, so
the job sheet and the invoice read as a pair of papers off the same outfit's
printer rather than two game screens. DESIGN.md has opened with "you run a
small landscaping outfit" since day one and it has never been on the glass.

tools/jobsheet/ — paste-ready for Lane A between CUT markers, purely additive
(.card/.row/.ledger untouched), rendered to four PNGs before handover, each
state addressable as ?v=. The ledger already WAS an invoice; it just never
said so. The line worth keeping is .void: a bonus that was on the job sheet
and didn't survive the night, struck through in the same column as the money
that did — a bonus you never watch fail is one you never learn to protect.

Two design calls left as A's with the reasoning beside them, same as the
carport's $180: the business name (proposal: HARD YARDS) and the ABN
(sequential digits on purpose — a plausible one could be a real business's).

bike_kid_01.glb — 1.15 x 0.33 x 0.74 m, 700 tris, lean baked in because a
bike doesn't stand up on its own. Two bugs caught by LOOKING at the render,
neither catchable by the existing suite: a comment claiming a step-through
above a diamond frame, and a fork with negative rake that drew head tube and
fork as one pole through the front wheel. Both passed the Blender verify —
a bounding box can't answer "is this the right shape".

The lean is an export-time axis fact (+Y in Blender is -Z in three.js), so
e.test.js now pins it in browser coords. Proved the assert fails: rebuilt at
lean=0 and Blender still said PASS while the selftest went red.

selftest 299/0/0. 32/32 GLBs byte-identical across three full factory runs.
2026-07-17 17:39:58 +10:00
type-two
6bcc2540cf Sprint 11 gate 2: the job sheet — five nights become five jobs
DESIGN.md has opened with this since line 3 — "every callout is a different
site, a different client" — and the game had the site and not the client. The
forecast card is a job sheet now and the aftermath is an invoice.

THE WEEK, once it was asked to name a client, mostly wrote itself: four nights
are the same yard, so they're one client on retainer while they're away (the
Hendersons, in Cairns from Friday — SPRINT11's own line). Night 3 is a
different SITE, so it is necessarily a different CLIENT: a short-notice callout
to a corner block you've never rigged, on the night the change comes early.
That isn't flavour bolted on, it's what `site: site_02_corner_block` already
meant, finally said out loud.

BUDGET Y IS THE FEATURE, not the letterhead. DESIGN.md's brief is "client wants
X, budget Y, forecast Z" and this card had Z alone. The fee has existed since
Sprint 8 and the player has never seen it until the money was already spent —
you chose what to rig without knowing what the job was worth, which is a reveal,
not a decision. The sheet quotes base + garden + clean up front, and a test pins
that a perfect night pays exactly what the sheet advertised: a job sheet that
over-promises is the game lying on paper.

THE CLEAN BONUS is the new money, and it's the one number in week.js that isn't
measured. It gives the invoice three axes that mean three different things —
you turned up, their garden lived, you didn't wreck the place — and it's NOT
gated on winning, because a lost night where you broke nothing of theirs is a
different fact and deserves its own row. It also makes the carport bite twice:
180 + 20 = 200, inside E's own "over ~250 is a silent game over" ceiling.
Measured: it adds ~$100 to a competent week (385 -> 485), which makes
BROKE_BELOW's known runaway worse. I did NOT pay for it by quietly trimming
feeFor or gardenBonusMax — those carry measured evidence, and re-tuning someone
else's evidence to fund my own feature is the move this repo keeps catching. If
gate 3 says the bank runs away, this is the lever to cut first and it's data.

TWO BUGS, BOTH CAUGHT BY LOOKING AT THE CARD, neither by a test:
 · "THE HENDERSONS" over "The Backyard — Your own place". The blurb and the
   letterhead called each other liars on screen. The blurb gave way: "your own
   backyard" is a leftover from when the game had one yard, and DESIGN.md never
   wanted it. The line's job survives — night 1 is the tutorial because you know
   this yard, now from having worked it for years rather than owning it.
 · C's `lead` is a 0..1 haze dial (confidence = 1 − lead), not "nights out". I
   read it as nights, passed 1, and the sheet advertised "forecast confidence
   0%". Now 0.6, because C's own hail rule calls L > 0.55 too distant to promise
   ice, and tomorrow is exactly that. C: the slot is mine, the number is yours.

Lane C's forecastLines(def, lead) finally has a caller above 0 — two sprints
after they built it. Lane E: .letterhead / .brief / .jobsheet / .tomorrow are
structure only and yours to style.

selftest 304/0/0 (was 300).
2026-07-17 17:37:18 +10:00
type-two
4aee055f2d Sprint 11 gate 1: three rulings — enum widened, carport priced, venturi reconciled
The corner block's seams, all of them A's to close.

ENUM (D's flag). Anchor.type was a closed JSDoc enum 'house'|'tree'|'post',
so site_02 typed its carport 'post' to fit — quietly enrolling it in the
sail-post family that C's venturi and B's audit both read. Widened to E's own
asset strings (carport, carport_post) and, more to the point, CHECKED: it was
documentation for ten sprints, and documentation cannot fail. validateSite now
rejects an unknown type by name. The ladder is untouched either way — D keyed
it on `work`, the mechanism, which is exactly why that was right.

CARPORT (E's proposal, adopted). $180, with E's reasoning intact: gnome 25, a
night's budget 80, so 180 is 2.25 nights — a good week turned broke and felt
for the rest of the run, without ending a strong one outright. Under ~120 it's
a shrug, over ~250 a silent game over that teaches nothing.

The number lives in site JSON, not the GLB: sites are data, and what a carport
COSTS is this site's economy, not a property of the mesh. E's baked value stays
as the fallback and the proposal.

And it BILLS now. E shipped the trap and said it plainly — the anchors said
collateral "carport" and nothing said what one cost, so nothing scored it. You
could lose the worst steel in the game and pay for a $15 shackle. One broken
corner is enough (the gnome needs two: it needs the sail to land on it; the
carport doesn't need the sail at all), priced per structure so two beams is one
carport gone, not $360. The wreck swaps on the same event — E built it to the
same origin for exactly this.

VENTURI (C's question). There was no disagreement to settle. weather.core
aligns on |dot(wind, axis)| because a gap funnels either way through it, so an
axis is a LINE: -1.08 + PI = 2.0616, and my shipped 2.1 was the same gap read
from the other end. Took C's number because C MEASURED it off storm_03b and I
eyeballed mine at authoring time; the 2.2 deg of my eyeballing was worth
0.44 m/s. gain left at C's 1.35 — that's a balance lever, theirs with B's audit.

Also wired validateSiteWind at site load, C's ask from Sprint 10.

selftest 300/0/0 (was 296).
2026-07-17 17:20:11 +10:00
type-two
8e23bf4702 Land leadFor(): the job sheet's per-night forecast lead
forecastLines(def, lead) always took the param; what was missing was the
map from "this night is N nights away" to a lead. leadFor(nightsOut,
weekNights) is that map, linear onto forecastFor's documented 0..1 domain
so tonight is exact and the week's far end is lead 1. Tomorrow at a
five-night week reads 0.25 — 75% confidence, sustained band ~±8%.

hud.js is Lane A's; this only exports the argument.

The new assert pins the property that makes a band safe to print: it
RESOLVES. lo rises and hi falls monotonically onto the truth as the night
approaches, because the centre-wander is the same seeded draw at every
lead — so tomorrow's band always contains tonight's and the card never
jumps. Verified against a mutation that reseeds per lead (caught).

Its containment half is honestly decoration today and is labelled as
such in the test: 0.6-of-half-width wander means c±w never crosses v, so
deleting the clamps does not make it fail. Kept as a contract guard for a
future rewrite of band(), not counted as coverage of today's code.

Selftest 298/0/0.
2026-07-17 17:14:53 +10:00
m3ultra
df1fd4dd25 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 16:39:27 +10:00
m3ultra
a7b859fbbf Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 16:39:26 +10:00
m3ultra
73e142947b Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 16:39:26 +10:00
m3ultra
a71d67e390 Merge remote-tracking branch 'origin/lane/e' 2026-07-17 16:39:26 +10:00
m3ultra
0f5d69b32d The second site: corner block loads from JSON; night 3 moves to it
SPRINT10 gate 1b. A night is now {storm, site}; the week hands over a yard per
night and night three is the corner block. Nights 1/2/4/5 are the backyard; the
plain-string night form still works and defaults to backyard_01.

site_02_corner_block.json: a smaller 24x16 yard with E's carport as the trap it
was built to be — beam anchors rating_hint 0.22, posts 0.30, both below the
house fascia's 0.35, collateral "carport". The site IS winnable off the honest
anchors (one tree east, four ground posts): I enumerated the honest quads at
authoring time and there are four in-band ones covering the bed, so the carport
is temptation not necessity. Full load audit is Lane B's tools/site_audit.

The carport's beams are keyed work:"bracket" — D's field, the MECHANISM not the
type. A bracket up a bare beam is the one anchor you can't fake from the ground,
so it needs the ladder; keyed on type it would fail open (a carport isn't a
house) and the ladder mechanic would silently vanish, which is exactly the audit
D ran. C's venturi list is in the JSON with two gap coordinates they asked for.

The world is rebuilt on a site change, not re-pointed: createPlayer captures the
world into its ground clamp, collider, ladder and broom (Lane D's modules, no
setters), so a clean rebuild is honest — and a site change only happens at a
forecast, never mid-storm. world.dispose() frees the old yard so nothing leaks.
The re-point of rig + session lives inside loadSiteInto, keyed on the rebuild
itself, after a caller-side version proved fragile.

Verified end to end through real phase transitions: play night 1, hit the
aftermath, and the forecast into night 3 rebuilds into the corner block —
carport present, 10 anchors (cb/cp/q/tr, not h/t/p), smaller bed, player rebuilt,
no scene leak. Then back to the backyard, 12 anchors restored. Selftest 289/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:05:06 +10:00
m3ultra
2996b092ae Prove the porosity->gardenHailExposure leak end-to-end (answer C's seam)
C asked in THREADS whether to land a one-liner making gardenHailExposure read
sail.porosity. Answer: it is already wired, in merged main — I did it in SPRINT9
(e576f5c): skyfx.step refreshes sailPorosity from world.sail.porosity, and
gardenHailExposure folds hailBlockFor(size, sailPorosity) into what it returns.
No one-liner needed.

But "already wired" was untested end-to-end. The only test of the hail leak,
weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with
hailBlockFor + hailAt — it proves the primitive and the arithmetic, not the
plumbing. A regression in the plumbing (the :738 refresh dropped, the
wind.def.hail.size lookup resolving wrong, hailBlockFor no longer folded in)
would leave that test green while the game quietly stopped leaking hail. That is
the measure-a-copy pattern this whole browser suite exists to avoid.

New assert 'porous cloth leaks pea hail into the garden, membrane blocks it'
drives the real sky.gardenHailExposure. The isolation is exact: ONE settled,
intact rig, sampled at ONE hail instant, porosity flipped 0.30<->0 between reads.
sky.step(0, t, {sail}) refreshes sailPorosity and rebuilds the shadow grid at the
same instant without advancing physics, so shadow geometry is identical across
the two reads and `block` is the only thing that moved. Measured:

    pea (size 0.7):  cloth 0.346 vs membrane 0.292  -> porous leaks +18%
    ice (size 1.4):  cloth 0.458 vs membrane 0.458  -> identical, the no-op

No storm flight, no second rig — deliberately. Two rigs whose corners diverge
share no shadow, and that difference is a cascade not a fabric: membrane cascades
on the ice night, so a naive two-flight version reads a FALSE ice-night
difference. The same-rig swap removes it.

And it can fail: if porosity stops reaching the exposure, both reads collapse to
the membrane value (measured: 0.2917 == 0.2917), cloth > membrane*1.05 is false,
red. The ice check is exact equality — if porous ever starts leaking big ice,
that goes red too.

Selftest 288 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:55:11 +10:00
m3ultra
b98dcad13a Sites are data: extract backyard_01.json; world.js builds from it
SPRINT10 gate 1a, carried since Sprint 8. Everything that was a module constant
— bed rect, house line, tree and post placements, shed, gnome, sun angle, fence
sides — is now data/sites/backyard_01.json, and world.js is a builder.

The extraction is byte-identical, and that's checkable rather than claimable:
E's branch-anchor tripwires pin five positions to 6 dp, a.test's quad-band
asserts pin the whole rigging geometry (>=3 quads in 18-45 m2, full coverage
still >45 m2), D's ladder field and B's balance lines all pass unchanged.
287/0/0.

It's byte-identical by construction, not by luck: the site-derived values keep
the builder's original local names, so ~500 lines of geometry that was already
correct is untouched and only its SOURCE changed.

Two things earned their place:

Anchors carry D's `work: "cloth" | "bracket"` — the MECHANISM, not the type. A
post is tensioned from a cleat at its base and a tree strop is thrown, but a
bracket bolted up a bare wall is the one you cannot fake. Keyed on mechanism
because `type` is a closed enum ('house'|'tree'|'post') that site_02's carport
doesn't fit, and needsLadder keyed on type FAILS OPEN there — the ladder
silently vanishes and you re-rig a 2.6 m bracket standing on the grass.

p4's swept position table moved INTO the JSON's `_why`, beside the coordinate it
justifies. That table is the only thing stopping someone nudging the post toward
the bed and collapsing DESIGN.md's central tension; it belongs with the number,
not in a file the number left.

validateSite() fails loud: sites are content, and a missing bed silently becomes
NaN coverage while a missing anchor list silently becomes an unriggable site.
createWorld now requires a site and says so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:34:29 +10:00
m3ultra
0cee198702 Price the carport trap and add its wreckage
The carport anchors have said collateral="carport" since Sprint 9, but the root
carried no collateral_value — so main.js, which reads world.gnome.collateralValue
for the gnome, had no number to reach for. A trap with no price is a warning
label. The root now carries it in the same shape and place, pinned above the
gnome's 25, with the proposed 180 and its reasoning as a named constant so Lane A
can argue with the number rather than the asset.

carport_01_wrecked is the payoff, on the intact one's origin for a mesh-for-mesh
swap: the pads stay (they were never the weak part), the frame racks over like a
parallelogram, the roof peels off downwind in one piece.

Two bugs found by measuring rather than reasoning, both now comments where the
numbers are: the beams were rotated when a beam spans the axis the frame racks
in, so it rides level — tilting it made the wreck taller than the carport; and
the roof sheets dipped below ground, which is what inflated the height. Asserts
now pin the wreck shorter than its twin and sitting on the grass.

291/0/0, Lane E 64 asserts, 40/40 output files byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:30:37 +10:00
m3ultra
5cb838b314 Add validateSiteWind — the site JSON's wind block fails loud (SPRINT10)
A builds world.js FROM DATA this sprint, and a site's `wind` block (venturi
funnels, later per-site shelter overrides) is hand-authored data like a storm —
so it validates like one. setVenturi already clamps defensively, but a clamp
hides a typo where the repo's ethos is to name it: a gain < 1 (someone reaching
for a shelter), a funnel with no throat coordinates, an axis typed "south". A
calls this at site load next to validateStorm. A site with no wind block, an
empty block, or an empty venturi list all validate — the funnel is opt-in.

Node 56/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:30:00 +10:00
m3ultra
13fc4bd14d Land needsLadder on the work field, measured against E's real carport
SPRINT10 gate 1. Adopts SPRINT10's spelling — `work: 'bracket' | 'cloth'` — as
canonical over the boolean I proposed in Sprint 9: naming the mechanism
positively beats a negation, and it's the field A is emitting. Sprint 9's
`worksAtBracket` is still honoured so nothing that already sets it breaks, and
`type === 'house'` remains the fallback, so site_01 is a no-op (verified live:
no anchor there carries either field, and fascia/post/tree verdicts are
unchanged).

Keyed to E's SHIPPED carport_01_v1.glb rather than my invented numbers, and it
confirms the audit exactly — it also carries BOTH mechanisms on one structure,
so a per-structure rule would still have been wrong:
    beam_anchor_01/02  y=2.36  anchor_type "carport"       rating_hint 0.22
    post_anchor_01/02  y=1.75  anchor_type "carport_post"  rating_hint 0.30
The beam sits over a 1.72 m person's 2.20 m reach and needs the ladder; the post
doesn't. Neither is typed 'house', so un-declared they BOTH fall back to "ground
work" and canReach waves a 2.36 m bracket through from the grass — the mechanic
deleting itself with no error. That regression is now pinned by a test.

E: the beam is the double trap — your worst rating in the game AND the one that
costs a ladder trip. That reads as a rule, not a bug, which is what §Lane D asks
for. A: the mapping you want is beam→bracket, post→cloth.

63 Lane D asserts headless, 288/0/0 in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:28:47 +10:00
m3ultra
855106d37e Merge Sprint 9: clean $80 win asserted; guard redesigned; pyrrhic wired
Selftest on merged main: 287 pass / 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:12:07 +10:00
m3ultra
d3258baaa8 Merge lane/d: keep B's nodeSpeed guard (same redesign, D holds veto)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:06:57 +10:00
m3ultra
1f25c05313 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 15:05:56 +10:00
m3ultra
b9bd65e67b Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 15:05:56 +10:00
m3ultra
e8fa8a736e Merge remote-tracking branch 'origin/lane/e' 2026-07-17 15:05:56 +10:00
m3ultra
6fa7644aba Redesign the settled-at-entry guard: ask the cloth, not the corners
SPRINT9 task for B+D. The demoted guard compared the worst corner's mean LOAD
across two windows and called the difference a "trend". The integrator's
diagnosis was that its clock was wrong — held at t=0, storm_02's compressed
rain ponds the cloth during the guard's own windows. That mechanism is real,
but it is not the bug, and neither of the two suggested fixes works. Measured
on the dressed yard, 0 s settle vs 12 s:

    probe under CALM, held in prep        13%  vs   17%
    probe under STORM, held clock         24%  vs  104%
    probe under STORM, advancing clock    24%  vs  104%
    probe under STORM, RAINLESS           17%  vs   96%

Every variant is either flat — calm cannot excite the cloth, so the guard is
vacuous and passes forever — or INVERTED, firing hardest on the properly
settled rig. Rain is not the culprit: the rainless probe ponds 1 kg and still
inverts. Advancing the clock isn't a fix either, because RAIN_TIME_COMPRESSION
is 40× and 4 s of storm is 160 s of rain regardless.

What a load-trend measures is the rig LOADING UP when the wind changes, and a
taut settled cloth ramps harder than a limp unsettled one (0.63 -> 1.23 kN vs
0.44 -> 0.52). The observable was pointed at the storm's arrival, not at the
cloth. No threshold, clock or rain switch fixes that.

"Settled" is a statement about the cloth, so ask the cloth. sail.js gains
nodeSpeed(): RMS node speed out of verlet. Sampled over 2 s of the same calm
prep the rig already stands in — no wind change to ramp against, no compressed
rain, and nothing left in the rig but two more seconds of prep a player does
anyway. Measured:

    settle    0 s     4 s      12 s     30 s
    speed    0.19    0.014    0.017    0.007  m/s    (pyrrhic quad: 0.23 -> 0.028)

An order of magnitude, in the direction the word means. Absolute check at
0.08 m/s, not a trend: the cloth breathes at 0.02 and the transient sits at
0.19, so there is real room for a line.

Promoted back from warn to a hard failure — and it earns that by being able to
fail. `the settled-at-entry guard can fail` flies the same line with NO settle
and asserts the guard refuses it. A guard nobody has seen fail is
indistinguishable from one that cannot, which is exactly how this one survived
two sprints being both vacuous and inverted. The control is flown up front in
run(), because Suite.test() does not await.

The old guard also left 43 kg of water and 4 s of storm load in the rig before
the storm started; the suite was flying a wet rig into a dry entry. It didn't
move the peaks (the storm's own peak dwarfs it) but it was never true.

D: this is yours to veto. Selftest 277 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:01:09 +10:00
m3ultra
e4436248b0 Add tools/site_audit; retract the p1 claim against C; show LANE BAL
site_audit answers "is this site winnable?" before it ships — the check that
was missing when SPRINT6 shipped a yard whose bed-covering quad pulled 7.4 kN
against a shop that holds 6.5. It enumerates in-band quads, flies the real
storm headless, maps each corner to the cheapest tier that holds it, and
verdicts against the $80 budget. On backyard_01 it independently reproduces
THE LINE: p1,p2,p3,p4 at $65 + $15 spare, matching balance.test to within 2%.

It also caught three of my own bugs, which is the point of building it:

  · it settled on STORM wind frozen at t=0 — the exact anti-pattern
    balance.test documents at 1.94 kN vs 0.40 kN. Now settles on the calm
    day on a running clock, the way main.js does.
  · peakLoad is peak-since-ATTACH, so the settle transient was being
    reported as a storm peak. sail.js gains resetPeaks().
  · its p4 was typed from world.js's postSpecs, missing that posts are RAKED
    8° away from centre — 0.56 m off. The audit now re-derives all four posts
    from live world.js on every run and hard-fails on drift.

That last check is deliberately narrow. Node CANNOT dress: dress() needs
GLTFLoader (bare 'three') and fetch()es .glb over file://. createWorld() still
SUCCEEDS in node — it just returns the GRAYBOX yard, house at x=±5, no branch
anchors. So a headless tool that "reads world.js for the real yard" gets the
fictional x=±5 house that cost two sprints. Reading live code is not reading
truth. Verified in-browser: the dump matches the dressed yard on all 12
anchors. Posts are the only thing dress() never touches, so they're the only
thing worth cross-checking — and that check is what caught p4.

C IS RIGHT ABOUT p1 AND I WAS WRONG, for the third time. My comment claimed
C's 1.08 kN didn't reproduce, that p1 peaked at 1.27 and the line won on TIME
rather than headroom. Re-measured on the dressed yard, same flight:

    shade cloth  p1 0.98 kN  0/4 lost      membrane  p1 1.23 kN  p1 LETS GO

p1 never touches its 1.2 kN rating on cloth — 18% of real headroom. Where 1.27
came from: a loadout where a corner BROKE and dumped its share onto p1 (the
same quad with hardware the budget can't buy reads 2.48). Any p1 number
gathered without checking `lost` measures a cascade, not a fabric.

So the fabric is the decision, not a skin: 0.98 vs 1.23 against a 1.20 rating
is the difference between the $5 carabiner holding the wild night and failing
it. New assert `fabric decides p1` locks that in, so nobody has to trust the
paragraph.

selftest.html rendered ['A'..'E'] while importing BAL too, so the balance
suite ran, counted toward the summary, and had every row silently dropped —
including, had one gone red, the row saying which assert failed. The merge
gate was invisible in its own report. My bug: I added the import, not the
renderer. Now derived from the report.

Selftest: 276 passed, 0 failed, 0 skipped (LANE BAL now visible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:52:31 +10:00
m3ultra
942709d2e9 Site-proof the ladder ahead of site_02, and test the real rule not a copy
SPRINT9 §Lane D: "play site_02 cold and log where the verbs break (the ladder
assumes fascia height; the carport may not have one)". site_02 isn't landed yet,
so I audited the verb against it instead of waiting.

The seam is real but it isn't height — it's that `needsLadder` is spelled
`type === 'house'`, and contracts.js types anchors as a closed enum
('house'|'tree'|'post') that E's carport doesn't fit. The failure mode is what
makes it worth fixing now: if needsLadder says false, interact's canReach returns
TRUE UNCONDITIONALLY, so the player re-rigs a 2.6 m bracket standing on the grass
and the mechanic silently stops existing. Same disease as StumbleBack's dead
threshold and the camera-less shadow grid — a rule keyed on the wrong thing,
failing open with no error.

A pure height test is NOT the fix: posts (3.95 m) and tree limbs (5.05 m) are
higher than the fascia and need no ladder, because you work the CLOTH there and
the bracket here. So needsLadder now reads data first —
`anchor.worksAtBracket` — exactly like anchor.ratingHint already does from the
GLB's userData, and falls back to site_01's type. Nothing on site_01 carries the
flag, so it's a no-op there (asserted). Lane A/E: set it in the site JSON and the
ladder follows the site with no code change.

Also made ladder.js headless-importable (lazy GLTFLoader, same as broom.js),
because d.test.js was testing fakeLadder's hand-copied duplicate of needsLadder
rather than the rule itself — a rule the suite re-implements is one it cannot
catch drifting. Verified the ladder's GLB still loads in the browser.

62 Lane D asserts headless, 275/0/0 in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:32:06 +10:00
m3ultra
4d4f2d7d7f Add carport_01 for site_02, and the pyrrhic end card
carport_01 is site_02's dressing and its whole design. DESIGN.md wants the
corner block anchor-poor, and the interesting way to build that isn't to give
the yard nothing — an empty yard is just a smaller yard. It's to give it
something that looks like four free anchors and isn't. Light posts on small
pads, a beam sized for one sheet of roofing: tie 25 m2 to it in a southerly and
you take the carport, not the shackle. Ships with the worst ratings in the game
(beam 0.22 vs the fascia's 0.35) and collateral="carport", pinned in e.test.js
below the fascia with the reason attached, so nobody quietly fixes them upward
and turns the site back into a small yard.

The pyrrhic card is the third outcome on the same camera: dawn, bed green, gnome
standing, sail post down across the yard behind him. Warm light on purpose — a
pyrrhic result is a win, and the wreckage is the price, not the verdict. Copy
goes to Lane A in THREADS; cardpreview renders all three.

277/0/0, Lane E 60 asserts, 39/39 output files byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:29:08 +10:00
m3ultra
19f4ab6c63 Venturi: per-site wind personality for site_02 (the corner block funnels the southerly)
A venturi is a shelter's opposite — a gap between buildings SPEEDS the wind up
when it blows along the gap's axis. Where a tree shadow depends on being downwind
of the tree, a venturi depends on the wind being ALIGNED with a fixed axis the
SITE owns, so it fires only when the storm's direction swings to match: the
corner block is calm until the southerly comes through, then it screams. That
interplay is exactly SPRINT9's brief — funnel geometry is the site's, the wind
that funnels is the storm's.

Extends the existing shelter system rather than bolting on a parallel one:
speedAt/vecAt/verticalAt all route through one localHoriz, which now folds
venturiFactor in alongside spatialFactor and shelterFactor. The downdraft rides
local speed, so a funnelled wind gets a proportionally stronger downdraft for
free — physically right.

`setVenturi(list)` on the wind + router (A's "add it to the router too" rule; the
tripwire is green). Inert on an empty list, so backyard_01 is byte-identical —
asserted. Multiplier is ALWAYS ≥ 1 (a venturi accelerates, never shelters), and
it's C1-continuous through the change, so it can't snap a corner — both asserted
(aligned wind boosts 1.5×, crosswind exactly 1.0×, max frame jump 0.50 m/s).

Data lives in site JSON (A's schema, arriving with the extraction) — this is the
physics + the setter, ready to wire. Proposed shape flagged for A in THREADS.

Also fixed a stale comment: storm_02's _gusts_comment still said "HELD at 0.12
for now" from Sprint 3 while the value has been 0.45 since Sprint 4 — a lying
comment in a repo that reads comments as canon. Now records the real history and
the pending paired-0.40 flip.

Selftest 278/0/0; node 55/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:26:24 +10:00
m3ultra
e576f5cb67 Fabric + 0.40 as one landing: the wild night has a clean $80 win
Selftest 275/0/0. balance.test: **$80 · shade cloth · p1,p2,p3,p4 ->
hp 63, 0/4 lost — CLEAN WIN.** First time the wild night has been
winnable without sacrificing the rig. C found the line and the $10 gap;
this spends both levers together, as they only work together.

Fabric is priced at $0 BOTH WAYS, and that is a finding, not laziness.
SPRINT6 asked me to price the difference; I measured it and there is no
price at which membrane is a sane buy — it is dominated:
  buys:  +26% hail, but only on the PEA-hail nights (hailBlockFor(0.7,
         0.3)=0.74), which are the easy ones — on storm_02 (1.3) and the
         ice night (1.4) a knit already blocks 100%, because a 2 cm stone
         can't pass a 2 mm weave (C's ruling). Plus rain, at 0.25 of the
         drain vs hail's 5.0 — ~5% of the damage.
  costs: double the wind load, and it PONDS.
Charge for that and you've shipped a trap. Free, it's DESIGN.md's actual
words — "fabric choice is a forecast bet" — and the bet is real: cloth on
the windy nights, membrane when the hail is small and the wind is mild.
If A ever raises rain's weight, membrane earns a price.

Wired C's hailBlockFor into skyfx.gardenHailExposure, which nothing
consumed — without it the fabric choice would have been cosmetic, which
is the bug class this repo keeps finding.

I did NOT reproduce C's numbers, and the comment says so rather than
repeating them. C reported p1 dropping to 1.08 kN (under a carabiner's
1.2) with cloth + 0.40. I measure 1.27, and p1 never crosses under 1.2 at
any combo. The line still wins on a $5 carabiner — but because 1.27 vs
1.2 is a 6% overshoot and breaking needs 0.4 s SUSTAINED, not because the
corner got under its rating. The margin is TIME, not headroom, and it's
thinner than the table implies: anything that lengthens storm_02's gust
HOLDS could take it. Flagged for C in THREADS.

The old t2 quad stays as the sacrifice-play control (hp 56, 2/4 lost) —
now a worse bet the shop will happily sell you, rather than the wild
night's only outcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:25:59 +10:00
m3ultra
601adedf45 Gate 0' guard: redesign it to measure SHAPE, and prove it fires
The integrator demoted my settled-at-entry guard to a warning at the Sprint-8
merge and handed B+D the redesign. Investigating it refuted the stated cause and
found a better observable.

The ponding diagnosis does not hold: every storm's rain curve starts at [0,0], so
storm_02.rainMmPerHour(0) is 0.0 mm/h and a held clock rains nothing. The real
confound was that the guard measured in the STORM's wind while the rig settled in
CALM — and that calm->storm step is one the REAL GAME ALSO HAS (wind.use() swaps
on the phase change), so it was never a harness artifact. Their 2 s windows were
also shorter than the cloth's breath: 2 s reads a 32% trend on a settled rig
where 3 s reads 6%.

But fixing the clock isn't enough, because LOAD cannot answer this question at
all. In calm there is no transient to see — the unsettled rig is LIGHTER (0.30 kN
vs 0.47 settled) and the trend test passes at every settle length from 0 to 20 s.
A guard that cannot fail is decoration, which is precisely what StumbleBack and
the fake skips were.

So ask the physical question: has the cloth stopped MOVING. Mean node drift
separates ~6x either side, and cannot be confused by wind or water:
    unsettled (0 s)  -> 212 mm/s  (cover quad) · 208 (miss quad)
    settled (8-20 s) ->  35 mm/s worst breath, typically 4-6
Limit 100 mm/s. Proved it FAILS before trusting it to pass: sabotaged the settle
to 0 s and it fired — "cloth still drifting 212 mm/s (limit 100)".

The integrator's 600 N floor was a sound amendment to the LOAD design; it isn't
carried over because drift has no units of force and 212-vs-35 is not a judgement
call. 274/0/0, all five balance asserts green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:25:08 +10:00
m3ultra
391722970a Rule the pyrrhic win: WIN = hp >= 50, corners priced not gated (SPRINT9 dec 1)
The garden is the client's. The sail is yours. Saving the bed with a rig that
tore itself apart is the job done expensively — not a failure. DESIGN.md doesn't
call a cascade a loss, it calls it "a firework you paid for", and *paid for* is
the point: the aftermath already bills the broken hardware, the collateral, and a
week's fee you didn't earn cleanly. Gating the win on corners bills you twice for
one night and then lies about it — the same species as the verdict that used to
tell a 4/4 hold they'd skimped.

B and D arrived here independently with the numbers, and B checked what it does
NOT break: cheap rigs still lose (4x carabiner -> hp 38), rigs that miss the bed
still lose (hp 36), $80 still cannot buy immunity. C's exhaustive sweep found the
only winnable $80 line on the wild night lands exactly here — hp 52, 1 lost.

The pyrrhic ending gets its own verdict, and it had to be checked FIRST: `lost >= 2`
was unconditionally a cascade-and-a-loss, and under the new rule it can be the
best night the game has. Two asserts pin both halves — garden saved + sail
destroyed is a win that still names the weakest link; the same wreck WITHOUT the
garden is still a plain cascade, because the garden is the whole job.

Selftest 276 pass / 0 fail (Lane A 32 -> 34).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:19:29 +10:00
m3ultra
704553c82d Merge Sprint 8; the week is live; balance harness stabilized
Selftest on merged main: 274 pass / 0 fail. Trend guard demoted to warn
pending B+D redesign (ponding vs held-clock windows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:02:53 +10:00
m3ultra
030349d152 Resolve Sprint 8 merge conflicts: week card + forecast bands; pyrrhic assert
hud.js: A's week forecast card now uses C's measured forecastLines with
uncertainty bands. balance.test.js: B's measurement-style assert kept (garden
half enforced, corner count reported as A's open design call).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:41:58 +10:00
m3ultra
d3bb7c2a93 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
#	web/world/js/tests/balance.test.js
2026-07-17 13:39:44 +10:00
m3ultra
0966aebeee Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 13:39:44 +10:00
m3ultra
a398d152b1 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
#	web/world/js/hud.js
2026-07-17 13:39:44 +10:00
m3ultra
a6ffd52209 Gate 0': the harnesses AGREE — and my settle claim is retracted
Ran every remaining suite-vs-game difference one at a time against the real
storm. None of them moves the corner count:

  live calm settle (running clock + calmWind)  entry 1.94 -> 0.40 kN, 2 lost
  live tree sway                               t2 4.33 -> 4.55 kN,    2 lost
  walked repair timing (~4 s)                                          2 lost
  all three together                                          hp 58,   2 lost

Then checked the structural candidates: anchors are bit-identical between
harnesses (all four, 0.0000 m), ring order is identical (p4,t2,t2b,p3), and
wind.sample() at t2 matches to 2 dp (36.49 m/s @ t=75.9) across suite, suite
with shelters, and the live router.

So I re-ran the live game itself, fresh page, A's exact line, tension 1.0:
  live  -> hp 58, 2 lost [t2,t2b], t2 peak 4.55 kN
  suite -> hp 58, 2 lost [t2,t2b], t2 peak 4.54 kN
They agree. The suite was right.

RETRACTED: my Sprint-7 "12 s settle flips 2 lost -> 1". It does not reproduce —
0 s and 12 s both end 2 lost, because the storm's own peak (4.54 kN at t=75.9)
dwarfs the attach transient. The settle is a real fidelity fix and NOT the
corner-count cause. B's arithmetic was right from their first post: t2 pulls
~4.5 kN, a shackle is rated 3.2, and the spare cannot save it (measured: t2b
blew @9 s, repaired, blew again).

Landed anyway, because it is still true and still worth guarding: fly() now
settles the way main.js drives prep (calmWind on a running clock, not the storm
frozen at t=0), and a new "the yard is SETTLED when the storm starts" assert
holds every run under 1.0 kN at entry. 266 pass / 1 red — the red is the
pyrrhic-win design call, not a bug, and it now says so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:19:10 +10:00
m3ultra
d8af945d91 Post the week; close the grass-atlas and screenshot-POST decisions
grass_atlas.png binned (six sprints, zero refs, Lane E's own recommendation).
Screenshot POST declined — yardshot works standalone, so it buys convenience not
capability. Saying no rather than carrying either an eighth sprint.

Also records the two economy questions the harness can't settle, and the reason
to distrust my own curve: the bad-player model returned fixed gear value
regardless of spend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:14:53 +10:00
m3ultra
52bdcd0a5e The week: five nights, one wallet, and an ending that tells the truth
SPRINT8 gate 1, carried since Sprint 6. week.js is a thin wrapper over the phase
machine, not a new system: it answers three questions between rounds — which
storm is tonight, how much money is there, and is it over. Zero THREE, pure data,
so the ladder and the money are testable at no cost.

The forecast card stops being a difficulty picker and becomes NIGHT n OF 5 with
the ladder as pips, tonight's storm, and the bank. Aftermath itemises the
settlement (fee / bonus / gear recovered / collateral / spent → bank), and E's
dawn tint comes up on their 2.2s ease BEFORE the scoreboard — their note is right
that you sell the survival first. E's diptych and copy are wired verbatim.

Two economy decisions, both made because measuring the week exposed them:

A lost night pays a FRACTION of the fee. Zero is unrecoverable and reads as
punishment; full pay means failure only costs the broken shackle and the bank
only ever climbs. Gear comes back at HALF — a full refund makes hardware free, so
the only sink is breakage and "broke = game over" becomes unreachable. Half is
also honest: a shackle that rode out a gale has been fatigued, which DESIGN.md
already says.

And the one that matters: REACHING night five is not SURVIVING it. My first draft
said it was, and playing the week found it — a $20-carabiner player loses four
gardens, never dips under the broke line because a cheap rig barely costs
anything, and got handed "THE WEEK HELD — everything's still where you put it"
over four dead gardens. The end card is the last thing this game says; it doesn't
get to lie the way the aftermath verdict used to. Words now scale with gardens
held: clean (5), scraped (3-4, E's own alternate copy), solvent (0-2, mine —
"STILL TRADING · The books balanced. The garden didn't." E never wrote words for
that ending because nobody knew the game could produce it).

Two economy questions I could NOT settle from a harness and have documented
rather than quietly tuned: a competent week runs the bank 80 → 350, so night five
stops being a decision; and a floor-scraping week may never actually go broke.
My bad-player model handed back a fixed $30 of gear whatever it spent, which
flattered the bottom of the curve — the same class of mistake this repo keeps
catching me in. Both want a human playing five nights.

Selftest 270 pass; the single red is gate 0's balance line, which is B+D's this
sprint and was already red on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:13:44 +10:00
m3ultra
756994d98e Ship forecast uncertainty to the card (carried twice — now visible)
The model landed in Sprint 6 and nothing ever called it, which is why this kept
getting carried: `forecastFor` existed while the card went on estimating inline.
So this is the wiring, not more API.

`forecastLines(def, lead)` turns a storm into the card's two stat lines, already
worded in A's voice. lead 0 = tonight = exact numbers (reads exactly as the card
always has); further out it hedges into ranges and prints a confidence line. The
bands always contain the truth — a forecast may be vague, never wrong, or a
player who rigs for the top of the stated range gets ambushed.

Two things the card gains beyond the bands:
- The numbers are now MEASURED. The inline estimate (`baseCurve peak + powBase
  + powRamp`) read 30 m/s for storm_02; it really gusts to 32.3, because gust
  power is drawn per gust and rides a ramp. The wild night now says 116 km/h
  because that is what hits you.
- The card finally MENTIONS HAIL. Hail has been the garden score since decision
  13 and the forecast never said so — you were being scored on something the
  card didn't tell you was coming. Now: "hail likely" on the wild night, silent
  on the gentle one, "possible" when it's too far out to promise.

hud.js is Lane A's file and A is meant to build the week uninterrupted, so this
is deliberately a 3-line swap that keeps their markup, classes and wording — the
week's card rewrite can carry it or drop `forecastLines` and call `forecastFor`
directly. `lead` defaults to 0, so nothing changes until the week passes one.

Verified live at both ends: tonight renders exact; five nights receding into the
week render 0%-confidence hedges ("16–28 m/s · gusts ~72–144 km/h · hail
possible"). Selftest 265/0/0 + gate 0's 2 skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:10:53 +10:00
m3ultra
807c02fb9e Kill the headless-zero-shadow trap at its source (skyfx)
step() opened `if (!camera) return`, which read as an innocent null-guard and
was in fact a physics gate: a harness with no camera skipped the shadow-grid
rebuild, so gardenExposure/gardenHailExposure reported full exposure no matter
what the cloth was doing, and every rig scored as if the sail did not exist.
hp 36 was the bare-bed constant all along. It cost gate 0 two sprints of four
lanes' time before A found it, and the fix belongs in my file, not in each
harness that trips over it.

A camera means "there is a view to place things in", not "the weather is real".
So the grids — what the sail actually DOES — now rebuild above the render gate,
with the yard centre as the wind-sample fallback; only drops, dome, lights and
audio sit below it. Headless callers get correct numbers and skip the 3k drop
matrices they were never going to draw.

Asserted both halves so it cannot come back: a camera-less skyfx shelters the
bed (hail shadow > 0.9, exposure < 0.1 under a panel), and the camera does not
change the physics — headless and viewed shadows agree to 1e-9. If anyone
re-adds an early return, that test goes red.

Selftest 264/0/0 + the 2 gate-0 skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:10:53 +10:00
m3ultra
f9fe75f122 Gate 0' CLOSED: the harnesses agree. Losing 2 corners is the shop
Drove the live game — settled through prep, rig verified unchanged across
the phase boundary (contaminated: false) — and it returns hp 58, 2 lost.
This suite returns hp 57, 2 lost. A's hp 58 was right and reproduces to a
point; A's "1 lost" was the miscount. The two harnesses now agree on both
numbers and the dispute is over.

All three SPRINT8 candidates tested; none flips the corner count:
  V1  settle under live calm wind + world.update sway  -> 2 lost, t2 4.4
  V2  ... and live sway through the storm too          -> 2 lost, t2 4.6 (worse)
  V3  repair delayed 0 / 3 / 6 / 10 s (a real walk)    -> 2 lost

Nothing could flip it, because losing 2 is not a bug — it is the shop:
  t2 4.6 kN, t2b 3.5, p3 3.5 all need the $30 rated shackle; p4 2.9 takes
  a $15 shackle. Holding all four = $105, $120 with the spare a repair
  needs. Budget is $80. Three corners sit above shackle grade and $80 buys
  two, so the wild night's best line ends hp 58 with the garden alive and
  the rig dead.

That is SPRINT8's escape hatch, and it's a design question handed to A:
`lost < 2` calls this a loss, DESIGN.md calls it the story. The assert now
asserts the half that IS physics (the garden must survive, hp >= 50) and
reports the corner count as the open question, so the suite states the
measurement rather than pre-empting A's ruling.

Landed D's settled-at-entry guard, as a windowed TREND test rather than an
instantaneous one: measured, the worst corner oscillates 0.9 -> 1.9 -> 1.3
-> 1.7 kN under constant wind because damping is deliberately light, so
there is no single settled value and a "has it stopped moving" check can
never pass — it would just be a flaky assert someone deletes. Comparing
consecutive 2 s means catches a real transient and tolerates the breathing.
Same lesson as the statics assert in sail.selftest.

Selftest: 266 passed, 0 failed, 0 skipped — green for the first time since
SPRINT6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:10:40 +10:00
m3ultra
abeefad72d Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
#	web/world/js/tests/balance.test.js
2026-07-17 11:51:44 +10:00
m3ultra
2a753a1570 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 11:49:48 +10:00
m3ultra
0d05cb1936 Land hailBlockFor(size, porosity) — the honest fabric-hail rule for B
B asked for the hail ruling before coding fabric choice; this is it as code.
The physics: porosity is about AIR (blows through → less wind load) and WATER
(drains → no ponding), NOT ice. A knitted shade cloth's gaps are ~1-3 mm; a
damaging hailstone is 6-45 mm, so it can't pass a mesh an order of magnitude
finer than itself — porous and membrane block the big stones identically. The
ONE true difference is the finest pea hail, which IS small enough to rattle
through an open weave. So: membrane stops everything; porous stops everything
except the smallest stones.

Verified across the storm sizes: shade cloth (0.3) fully blocks the wild-night
1.3/1.4 stones and leaks 26% of storm_03's 0.7 pea hail; an open 0.5 weave
leaks 90% of pea hail but still catches the big ice. That makes the fabric
choice cost you exactly on the mild-hail nights and stay honest on the ice
nights — a real tradeoff without a physics lie.

The assert also proves the integration end to end so nobody wires it blind: a
membrane-covered bed takes ~0 hail on storm_03, a porous-covered one takes 0.62
— the choice is genuinely non-trivial. Formula for whoever wires the garden
drain: coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity).

Pure helper (no THREE), exported through weather.js alongside stormStats/
forecastFor; NOT on the wind contract, so no router change. Selftest 263/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:30:37 +10:00
m3ultra
f8710b56bc Sweep and taper the tree limbs; pin the anchors that ride them
Each limb was one straight uniform tube — a peg poking out of a pole, and the
most visible art problem in docs/yard_day.jpg. They now leave the trunk thick,
sweep up on a bezier and taper to nothing, which is what a gum does. +96 tris on
gum_01, silhouette bounds unchanged.

Nothing physical moved. The tip is a bezier endpoint and the four rng draws per
branch are the same four in the same order, so every branch_anchor_* is
bit-for-bit where it was — verified before and after, all five identical to 6dp.
Lane A's winning line rigs off t2 and is untouched; no balance number can shift.

e.test.js now hard-codes those five world positions as a tripwire, so no future
art change of mine can quietly invalidate a sprint of someone else's physics. If
it goes red, the art moved an anchor — fix the art, not the numbers.

Card text and the dawn-tint treatment go to Lane A in THREADS. The tint is a CSS
snippet rather than a ninth texture: only two of my eight are consumed, and a
full-screen gradient needs no asset at all.

Selftest 262/0/0, 38/38 output files byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:25:03 +10:00
m3ultra
aac1030131 Gate 0: name the harness dispute's cause; make skips real (testkit)
THE CAUSE, and it is not a winner: balance.test.js built skyfx with no camera,
and skyfx.step() opens `if (!camera) return;` — so the hail shadow grid was
never rebuilt across all 5400 calls and every loadout was scored as though it
had no sail. hp 36 IS the bare-bed number. Measured with the camera as the only
variable: hailShadowOver(bed) peaks 0.000 vs 1.000, hp 36 vs 69. Neither harness
was lying; one was flying a yard with no cloth in it. Camera + wind shelters
added — the suite must drive what the game drives.

MY ERROR, named: the hp-58 win was measured at tension 1.0 and I never declared
it. At the shop's default 0.9 my own end-to-end also loses 2 corners.

STILL OPEN: this suite says 2 corners lost, my end-to-end says 1 at tension 1.0.
Ruled out by measurement, each worth <=0.02 kN: shelters, frozen vs live tree
sway, the scripted repair. Next suspects named in the skip: session.commit() vs
rigSail(), and main.js passing debris as rig.step's 4th arg (debris ADDS load,
so it should break MORE here, not less — that inversion is the thread).

And the find that explains how this survived a merge: Suite.test() ignored its
return value, so `return "SKIPPED — ..."` was recorded as a PASS. The
integrator's storm_02 skip was a fake pass; so was mine for storm_01; `skip: 0`
in every report we ever printed was the tell and nobody read it, me included — I
misread one of those fakes as my win reproducing. test() now honours the string,
and rejects async fns outright (they hand back a Promise that cannot throw
synchronously, passing forever while proving nothing).

That guard immediately caught its own author: BOTH my wind-router tripwire tests
were async and have asserted nothing for two sprints — including the sprint where
I reported the tripwire "caught its first real omission". It had, in a hand-run
probe, never in the suite. Awaits hoisted into run(); they asserted for the first
time today, and pass.

Selftest 261 pass / 0 fail / 2 honest skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:21:01 +10:00
m3ultra
098fcc963b Gate 0: refute A's win, name the cause, delete the skip
A measured t2,p3,p4,t2b on 4x shackle + spare at hp 58 / 1 lost. This
suite says hp 36 / 2 lost. Went through every way the two harnesses could
differ and ruled each out by measurement, not argument:

  tree wind-shelter   on vs off        -> hp 36 either way
  tension             0.6/0.75/0.9/1.0 -> hp 36, 2 lost, all four
  drain wiring        garden.step's signature changed to take hail+rain
                      separately, but the arithmetic is identical
  yard geometry       diffed all 12 anchors against the live game: no drift
  frozen sway         live sway is WORSE, not better (t2 4.4 -> 4.6 kN)

What's left isn't a harness difference, it's arithmetic. t2 peaks
4.4-4.9 kN on this quad; a shackle is rated 3.2. t2 cannot hold under any
variation above. Losing it spends the only spare, then p3 (3.7) or t2b
(3.4) — also over 3.2 — goes too. Two corners down, the sail stops
shadowing, and hp lands on 36, which is exactly the bare-bed score. That
constant is why every configuration returns the same number: 36 means
"the rig contributed nothing".

Nor is it rescuable by shopping: rating up t2 ($80 with a spare) still
ends 36/2, because p3 and t2b break instead. The quad needs three corners
above shackle grade and $80 buys two.

So the skip is deleted and the assert is RED again, which is the true
state. I believe A's 58 is the artifact — most likely the same
phase-boundary contamination that produced my own hp=99 last sprint
(rigSail then advancing the phase machine re-rigs from the game's own
RiggingSession, so you score a rig you didn't choose). A: assert your
rig.corners' anchorIds and hw names immediately before your storm loop.
If they match what you bought, I'm wrong and the cause is still open.

Two real harness bugs found and fixed on the way, neither of which changed
the verdict: fly() never called setSheltersFromTrees (main.js:369 does),
and shop() hardcoded tension 0.9 while the dial's neutral is 1.0. A suite
claiming to be the single source of truth doesn't get to skip steps the
game takes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:10:22 +10:00
m3ultra
676b58a2bb Merge Sprint 6 lanes; wire hidden_by_default; document the balance harness dispute
Selftest on merged main: 263 pass / 0 fail (winnable-line assert self-skips
pending SPRINT7 gate 0 harness convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:47:29 +10:00
m3ultra
26529eeb58 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 09:35:12 +10:00
m3ultra
7025ea847e Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 09:35:12 +10:00
m3ultra
4180e188e2 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 09:35:12 +10:00
m3ultra
903e1d5f95 Merge remote-tracking branch 'origin/lane/e' 2026-07-17 09:35:12 +10:00
m3ultra
121e169981 Soften Ice Night: 2.8x storm_02's hail was a wall, not a night
Measured against A's gate-1 winning line (t2+p3+p4+t2b, 4x shackle + spare,
tension 1.0, one repair) under an identical harness: the first draft's 26 s
hold gave 32 hail-seconds and landed ~39 HP below storm_02. That is not a
harder night — no amount of play recovers 39 HP when you are already flying the
best rig the shop sells. Hold 26 -> 12 s puts it at 21.1 hail-seconds (1.6x
storm_02, was 2.8x) and 13 HP below it, which excellent rigging + the repair +
the broom can answer — and night 5 arrives with four nights of banked money,
which is the campaign's own answer to escalation.

Week now: gust 11.3 / 21.4 / 21.2 / 32.3 / 28.3, hail-seconds 0 / 2.4 / 2.4 /
11.4 / 21.1. Force ramps then plateaus while the question changes underneath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:29:17 +10:00
m3ultra
08a1d95903 Add night dressing and end cards; fix the hide_render bug
hide_render does not survive glTF export — verified in three r175, every node
arrives visible:true. So garden_bed has been drawing plants_full, tattered AND
dead superimposed since Sprint 1, and my THREADS note told Lane A the opposite.
The asserts missed it by checking the nodes existed rather than that only one
was on. The flag now rides in extras, which does export, and consumers apply it
in one line; e.test.js pins the flag instead of the node.

Night dressing: house_yardside gains window_glow, an unlit emissive pane marked
hidden_by_default, plus window_light_anchor. A warm window means someone is
inside, which is why the player is out in the dark keeping the sail on. Plus a
moon sprite whose halo, not its disc, is the part that survives cloud.

End cards are rendered from the game's own props as a diptych: identical camera
and yard, only the night's outcome differs. The left third is left empty for
Lane A's text.

Cards are opt-in behind --cards because EEVEE's shadow pass is not
byte-reproducible across processes in 5.1 — proven with a minimal repro, not
PYTHONHASHSEED, and present even with a hard sun. The dawn shadow is the art, so
the cards become committed art rather than build output. Everything the game
loads stays byte-identical: 38/38 verified.

Selftest 241/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:28:26 +10:00
m3ultra
98bc11f033 Add balance.test.js — SPRINT6 gate 1. It is RED, and that is the point
The gate asks the only question no per-lane suite can: can the night be
won through the real $80 shop. It buys loadouts via RiggingSession, flies
the real storm JSON over the real yard, integrates skyfx's real hail/rain
exposure into main.js's real garden drain, and judges with main.js's own
win rule.

Status: storm_01 warm-up PASS, cheap-rig-punished PASS, decision-13
miss-the-bed control PASS, and **storm_02's winnable line FAILS** at
hp=36, 2/4 lost. That reproduces the integrator's finding exactly and is
the gate doing its job — SPRINT6 says everything waits on gate 1.

Two things I got wrong on the way, both worth the comments they now
carry:

- The first draft hardcoded the anchor table from a THREADS entry and
  was badly wrong (the dressed yard has the house at x=±3, not ±5). It
  flew a fictional yard. It now reads the yard from world.js and freezes
  only the sway — the yard IS the balance, so it cannot be a copy. Same
  failure as Sprint 3's 16.7° reference rig.

- The asserts were async. testkit's Suite.test() calls fn() WITHOUT
  awaiting, so every one would have passed forever while proving nothing
  — the exact vacuous-test pattern I've flagged three times. Storms are
  now flown up front in run() (which runAll does await) and the asserts
  are synchronous judgements over the results.

Registers as a sixth 'BAL' entry in selftest.html. A: your "nobody
touches this file" rule guards against five lanes conflicting here; one
jointly-owned entry is the case it makes room for. Revert if you disagree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:25:53 +10:00
m3ultra
40be89c238 The week's two storm variants + forecast uncertainty
Variants for A's five-night week. Each gets ONE new trick, and neither is
"the same storm but harder":

- storm_03b_earlybuster (night 3): same force as storm_03 (21 m/s gust, 13
  sustained) but the change lands at t=18 instead of t=30. You rig for a hot
  NW'er and get a southerly a third of the way in. Tests timing, not budget.
- storm_02b_icenight (night 5): LESS wind than the wild night (28 vs 32 gust,
  19 vs 20 sustained) and 2.8× the hail — 32 hail-seconds vs 11.4, held 26 s
  straight across the change, size 1.4 stones, ice on every gust ≥9. Hail is
  the score (decision 13), so this asks the one question storm_02 only asks
  for nine seconds: is the bed actually covered, and does the cover hold? A rig
  that survives storm_02 by hiding in a small quad off the bed loses here.

Week escalation now reads 11.3 / 21.4 / 21.2 / 32.3 / 28.3 m/s peak gust with
0 / 2.4 / 2.4 / 11.4 / 32.0 hail-seconds — force ramps then plateaus while the
question changes underneath it.

Forecast uncertainty (DESIGN.md partial-information canon): `forecastFor(def,
lead)` + `stormStats(def)`, pure functions on a def rather than methods on the
wind — the card holds defs, doesn't want a field, and this keeps forecasting
off the wind contract and out of A's router. Bands widen with lead and resolve
to the exact truth at lead 0; deterministic per storm, so re-reading the card
can't reroll it.

The invariant, asserted across every storm at five leads: a band ALWAYS
contains the truth. That's the line between partial information and a lie — a
player who rigs for the top of the stated range must never be ambushed.

stormStats also measures what the card was estimating: storm_02 gusts to 32.3
m/s, not the 30 that `baseCurve peak + powBase + powRamp` implied.

Selftest 253/0/0; node 49/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:24:06 +10:00
m3ultra
106a33cb31 Lane D: calibrate the douse to B's real ponds; fix the sagging-belly refusal
Rebuilt the broom against B's FROZEN contract, which is not the shape I asked
for and is better: pondCentroid() (one pond, not a list — B's belly pools into a
single heaviest place) and drainPondAt(node, dt, radius) draining PER FRAME and
returning the kg shed that call. Added interact `onHold` for actions that do work
over the hold rather than at the end of it; the total is only knowable by
accumulating. Falls out nicely: an interrupted poke sheds only what it got
through, which is the honest rule.

Calibration, measured on B's live sim rather than modelled (flat rig, storm_02,
pond mass at poke → kg shed → outcome):
     8s ·  85 kg →  76 shed → wet      (caught it before the ticker)
    12s · 152 kg → 104 shed → stagger  (you answered the warning)
    20s · 281 kg → 182 shed → knocked  (you ignored it)
Bands sit under those so all three are REACHABLE — my Sprint-5 guesses (60/120)
would have flattened the player on every poke, and my first pass at real numbers
(150/350) put the knockdown above anything this rig can shed, i.e. dead code.
That is the StumbleBack lesson exactly, so d.test.js guards reachability with the
measured numbers and will fail loudly when gate 1 moves pond masses.

Bug the live sim found that no stub could: a loaded belly SAGS toward you — real
ponds ride from y=1.2 down through y=-1.0 — and my reach gate rejected `up < 0`,
so the broom answered "nothing pooling here" with 281 kg hanging over the garden.
Sagging toward you is the mechanic working. Gate is now a physical band between
sagFloor and reach, with the floor set between two measured cases: a live 281 kg
belly at y=-0.8 (pokeable) and post-tear wreckage at y=-4.5 (nothing to poke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:23:17 +10:00
m3ultra
2af4662cd7 Add p4, the one close anchor — the wild night is now winnable (gate 1)
SPRINT6 gate 1's anchor lever, placed by measurement rather than by guess.

The wild night had no winnable line for a geometric reason: every anchor near
the bed (p1/p2/p3) is SOUTH of it, and nothing stands north short of the house
10 m away. So covering rigs had to span the yard and died, while rigs small
enough to survive sat beside the bed rather than over it. p4 supplies the
missing north-west corner.

Its position is swept, not chosen. Against the smallest quad covering 90% of the
bed: (-2.2,-1.2) → 44.4 m², (-3.2,-1.2) → 48.6, (-3.2,-2.0) → 51.7, beyond
z=-4 → no effect at all. Pulling it inward to 44.4 would put full coverage
INSIDE the survivable band and collapse DESIGN.md's central tension — covering
the bed has to cost risk. a.test.js's >45 m² floor is what shouts if anyone
moves it in. With the 8° rake the top anchor lands at (-3.72, -1.40), and full
coverage costs 51.6 m² against 63.5 before: a real new option, 19% cheaper,
still bigger than the 23-38 m² rigs that survive unaided.

Measured through the real $80 shop: t2+p3+p4+t2b, four shackles and a spare
($75), ends hp 58 with 1 corner lost — a WIN, where before every covering rig
lost 2-3 corners and ended at 36%. Hail damage falls 51 → 34 HP versus an
uncovered rig, so decision 13 is visibly doing its job.

Selftest 244/0/0; both yard tensions still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:21:25 +10:00