Compare commits

...

27 Commits

Author SHA1 Message Date
type-two
03c6cd1c09 Sprint 13 integration: merge all lanes (362/0/0), apply B's pre-authorized gardenfly resolution, [I] entry 2026-07-18 14:30:13 +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
9970abc474 Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-18 14:26:16 +10:00
type-two
b53d4c50f7 Lane B S13: THREADS — audit rewrite receipts: agreement table, the 12s-skew finding, the re-posted pay table, two retune asks for A
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:52:06 +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
08f0e09ff0 Lane D: gate-1 and gate-2 verdicts — played through the real UI; bench venturi landmine
Gate 1: C's model confirmed to the decimal in play (backyard 61.3 exact, carport
82.6 exact, fascia 2.27 eff / t=1 settle tear, QA 88 reproduced). The $80-line
wildnight headline does not survive the real game: probe4/bench read venturi off
the storm def, the site def is where it lives — funnel on, q3+q4 break before
the hail and the line reads 39.8 vs cheap 38.5. Separation table for A's ruling;
the proposed target is currently satisfiable nowhere (bare never reads DEAD).

Gate 2: storm grade, wall (both QA sightings dead from in-yard), leaves (cap 7,
one-wind onset with the lean), lean-under-grade, A's spawn fix (160 px clear on
receipts), hail sliver dead twice over. Plus two sightings: last night's sail
haunts the next prep across a site change; hidden-canvas boot NaNs the camera.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:31:28 +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
a495fcdba6 Lane C S13 gate 1: the shadow model is right — the LINE was wrong; garden_bench proves it
The gate-1 diagnosis the sprint chartered, inverted by measurement: the shadow
geometry is exact (probe2: raycast and rasteriser agree 1.0000/1.0000), the
canonical measuring lines just don't cover the bed against vertical hail, and
site_02's tr1+q1+q3+q4 at exactly $80 already separates 91.5 FULL vs 35.7 bare
(sep 55.8, 0/4 lost) with no model change. Full findings + separation-target
proposal in THREADS.

Re-verified after the authoring session died un-pushed: all headline numbers
reproduce; the bench drives RiggingSession.commit(rig) -> rig.attach(), not the
bypassed-attach seam D's landmine names; balance.test's canonical shade-cloth
peaks reproduce per-anchor to the last digit. One fix over the original staging:
probe3/probe4 labelled per-corner peaks in plan order, but rig.corners is ring
order — labels now come from corners[k].anchorId (values were always true; the
$80 line's two RATED corners read swapped). Selftest 335/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:00:09 +10:00
type-two
94624cddcc Lane D: status + garden-harness landmine note (parameter shadow + skipped attach)
Done/green on lane/d: wind lean, ladder knockdown fix, label, E clip
contract, C/B diagnoses. Gate-1 verdict and gate-2 visual judging blocked
on C (still at integration commit).

Documented a measurement trap before it becomes a false alarm: a headless
garden-separation harness showed zero separation, which looks like 'the
sail casts no shadow'. It's an artifact — (1) sky.step's third param is
named 'world' so world.sail is the passed {sail:rig}, not the global; (2)
session.commit() bypasses onCommit->rigSail->rig.attach, so the sail is
never built and every rig scores as bare (the hp-36 constant skyfx.js:803
warns cost gate 0 two sprints). Garden must be measured via real play or
the full attach path. No baseline numbers posted — a bypassed measurement
is worse than none.
2026-07-18 01:52:10 +10:00
type-two
f3136093fc Lane D: THREADS — root lean landed, closes E's gate-2.4 handoff
Wired the wind lean as a root pose per E's analysis. Keyed on windBase not
raw speedAt (measured: raw strobes 8-18 flips/min, windBase 0-2 per storm).
Verified live at 71 km/h, leanDir dot wind = 1.0. gate 2.4 wind-clips line
is done with zero new clips: brace and stagger already shipped/wired, lean
is root. Open question to E: is the WindPosture additive worth authoring.
2026-07-18 01:35:02 +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
666b378740 Lane B S13: rule D's fuse line — depth to keep; audit is blind to it (flag)
D's mixed-hardware exhibit, measured on the corner block carport quad:
  unbreakable (audit's view): cb1 peaks 3122 N
  fuse (cb rated + q carabiner, $70): q3 blows at the change, sail sheds,
    cb1 peaks only 1169 N — holds, pays +$147
  uniform rated ($120): sail stays intact, cb1/cb2 both blow at 1602 N > 1430

The cheap corner is a genuine fuse — paying more breaks the beam, paying less
saves it. Ruling: it's real, physical, legible depth. Keep it.

The hole is in the tool: auditSweep flies unbreakable hardware, so it prices
every corner to its unbroken peak and marks D's winning quad
"cb1 3.1kN->NONE, affordable:false" — unwinnable. Third face of the SPRINT6
trap (unriggable -> cheap -> unwinnable), each time because the tool modelled
less than the sim. It's a false FAIL not a false PASS, so it never called the
site unshippable — but it hides the best-value line from guidance.

Fix folded into the post-C re-audit (fly breakable hardware, detect the shed):
the "best net" claim is garden-inclusive and moves when C's model lands, so
measuring it now measures a number that changes. Flagged, not rushed.
2026-07-18 01:14:37 +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
7053623fde Lane D: diagnoses for C (hail sliver is skyfx, not my ladder) and B (free infinite spares)
- The aftermath 'ghost sliver' QA blamed on my ladder is C's hail
  InstancedMesh at the origin: count:0 but frustumCulled:false +
  depthWrite:false leaves instance-0 drawn as a 5cm always-on-top speck.
  Fix is mesh.visible = n>0 or scale-to-zero when intensity is 0. skyfx.js.

- The shed-table spare is free and infinite: interact.js gates only on
  !carrying and never reads rigging.session.spares. Took a spare 3x with 0
  bought. Deletes the spare economy the ladder cost is balanced against.
  Cross-lane: the count lives in B's session, main.js wires
  wireYardActions without it. Needs A to thread rigging in; B owns the
  economy. Flagged both. Should land before gate 4 (public difficulty hole).
2026-07-18 01:01:51 +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
91bc391633 Lane E, Sprint 13: no wind clip — the pipeline can't supply one, and the lean was never a clip
Gate 2.4 asked for lean/stagger/brace clips staged by wind band. The answer,
measured twice, is that the asset shouldn't exist.

1. The lean cannot be a clip. _rotOnly drops Hips.quaternion from every clip at
   load (player.js:53, applied :239). Parsed the shipped pack to confirm rather
   than trust the read: 17 clips, 195 channels each, 64 survive. A lean baked
   into a clip's hips is discarded. And stripping it is right — the wind's
   bearing swings (the change; site_02's venturi) and a canned clip has one
   fixed lean axis, so only the sim can aim it. Same trick as the knockdown and
   climbY. The lean is D's root pitch; thresholds proposed off Beaufort 7
   ("inconvenience felt when walking against the wind" = 13.9 m/s), which lands
   storm_01 never leaning, storm_03 gusting through the line, storm_02 in gale.

2. Mixamo has no wind animations. Searched the real API, not assumed: shiver
   returns zero, brace returns eleven ledge-hang clips, wind returns Catwalk
   Turn. The factory header already recorded this in Sprint 3.

Rendered the two real candidates on a skinned rig, twice each — as authored and
as the game shows them. Pushing is a squat shoving a couch. Leaning is a man
waiting for a bus. Both rejected. Both would have passed every check we own:
they load, they're metre-scale, their nodes survive. Dims can't answer "is this
the right shape" — the bike rule, catching a whole feature this time.

Brace and stagger already ship (TakeCover, StumbleBack, Reaction). D should wire
the root lean first; I'll hand-author a WindPosture additive into the gap we can
both see, if one remains. Spine rotation survives _rotOnly, so it would land.

Evidence, harness and repro in tools/character/windclip_candidates/, plus the
four pipeline traps I hit in order so the next lane doesn't.

selftest 335/0/0, unchanged — no asset added, no shipped file touched.
2026-07-18 00:45:22 +10:00
type-two
d5c16534c1 Lane E: wind-clip proposal to D — the lean can't be a clip, names + Beaufort thresholds
Posting before export, per SPRINT13 gate 2.4 ('agree the clip names and band
thresholds with D in THREADS before you export').

The headline is a correction to the brief, measured not argued: _rotOnly drops
Hips.quaternion from every clip at load (player.js:53, applied :239), so a lean
baked into a Mixamo clip is discarded. Parsed the shipped GLB to confirm — 17
clips, 195 channels each, 64 survive. The lean has to be a root pitch in D's
sim, aimed at wind.dirAt(t), because the wind's bearing swings and a clip's
lean axis can't. Same trick as the knockdown and climbY.

Thresholds anchored on Beaufort 7 ('inconvenience felt when walking against the
wind') = 13.9 m/s, which lands storm_01 never leaning, storm_03 gusting through
the line, storm_02 in gale all night. Not tuned to get that.

Mixamo has no wind clips — searched the real API, not assumed.
2026-07-17 23:42:32 +10:00
type-two
e839f5afe3 Lane B S13: garden_probe — the sail's value is the hail it catches
Gate 1 evidence, posted early because C's model fix and A's export are both
downstream of it.

garden_probe.html flies the sim's own garden loop (skyfx gardenExposure +
gardenHailExposure, stepped the way main.js does) per rig line and prints the
garden HP the sim would report. Browser-only by necessity: skyfx needs
`document` (node throws), so garden prediction can never live in audit.mjs.

The finding, every corner held on rated shackles (the sail's best case):

  night 1 gentle       no hail   bare 97.6  best 99.1  sail worth +1.5
  night 2 southerly    0.7       bare 83.7  best 90.1  sail worth +6.4
  night 3 buster       0.7       bare 82.6  best 90.1  sail worth +7.5
  night 4 wild night   1.3       bare 35.7  best 60.5  sail worth +24.8
  night 5 ice night    1.4       bare  0.0  best 27.7  sail worth +27.7

The separation IS hail size, exactly as A's GARDEN_DRAIN comment predicted:
cloth blocks 100% of 1.3+ stones, 74% of pea hail, and rain at 73 degrees walks
under and does nothing. No value of GARDEN_DRAIN can separate a good rig from
none — it scales both by the same factor.

Also found: my own audit's cover% is the SUN shadow, which C's skyfx comment
already called "a number about nothing" during a storm. On the wild night it
predicts the garden BACKWARDS (r = -0.42): the best garden line is p1,p2,p3,p4
at 25% cover / $65 / 60.5 HP, beating a 71% cover / $120 line at 52.8 HP. D's
"$20 rig banked 39/45" and the QA's +$97 are the same fact — I sorted the table
by the wrong quantity.

Flagged to A: the ice night's garden cannot be saved at any price (best 27.7 vs
WIN >= 50), and night 1's sail is worth 1.5 HP.

The probe carries a TEMPORARY marked copy of main.js's private garden constants;
asked A to export the model so audit and sim share one copy. Nothing ships
against the copy — the probe is evidence, not a feature.
2026-07-17 23:41:41 +10:00
type-two
8d0045bb29 Lane D: clip contract for E — measured wind bands, windBase signal, and the fetch blocker
Gate 2.4 asks E to agree names+thresholds with D before exporting. Filed:

- Blocker E can't see from a JING5 clone: build_player_anims.py's source
  libraries (~/Documents/FBX, 3D=models/animations, mixamo-fetch) do not
  exist on this laptop. All three are on ultra. The pack ships built but is
  not rebuildable here.
- The asked-for clips aren't on disk anywhere (only Stumble Backwards and
  Taking Cover are lean-adjacent, both already shipped), and new Mixamo
  fetches need John's manual browser login. Brace and stagger are ALREADY
  shipped and wired (TakeCover on KeyC, Reaction/StumbleBack on gusts) —
  the lean is the only genuinely new posture.
- Bands measured at the player spawn across all five storms:
  calm <8.3 / lean 8.3-17 / gale >=17 m/s, hysteresis +/-1.5. 8.3 m/s is
  30 km/h, the same number C's leaves start at. Band ladder lands on the
  storm rating ladder.
- Signal is sim.windBase (existing ~4s EMA), not raw wind.speedAt: raw
  strobes at 8-18 band flips/min, windBase gives 0-2 per 90s storm.
  Sustained = posture, gust = event. Deviation from the prompt's literal
  wording flagged to A with the table.
2026-07-17 23:40:28 +10:00
36 changed files with 4167 additions and 141 deletions

1069
THREADS.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
# Wind clips — the Mixamo candidates, and why they're rejected
Lane E, Sprint 13 gate 2.4 ("wind clips for the player — lean / stagger / brace,
staged by wind band, through the Mixamo pipeline").
**Outcome: no clip shipped. The Mixamo pipeline cannot supply a wind lean, and the
lean was never going to be a clip in the first place.** Both halves of that are
measured, and the evidence is in this folder. Full argument in THREADS [E] 2026-07-17/18.
## 1. Mixamo has no wind animations
Searched the real API (`fetch.cjs search`, on ultra), not assumed:
| term | what comes back |
|---|---|
| `brace` | eleven **Braced Hang** ledge-climbing clips |
| `wind` | Standing Idle 04, Catwalk Walk Turn 180 |
| `shiver` | nothing, 0 results |
| `stagger` | three walks (Swagger Walk, Walking, Walking) |
| `lean` | Leaning On A Wall, Leaning, One Shoulder Lean |
| `push` | Pushing, Push Up, Button Pushing |
The factory's own header already recorded half of this in Sprint 3 — *"Hammering /
Sweeping Floor / **Bracing** don't exist on Mixamo — skipped."* This is the case
the asset-pipeline skill names directly: *truly bespoke clips don't exist on
Mixamo — hand-author, don't keep searching.*
## 2. The two real candidates, rendered and rejected
`skinshot.py` films a skinned Mixamo FBX twice: **as authored**, and **as the game
shows it** (Hips rotation cleared, which is what `player.js:_rotOnly` does at load).
The second row is the honest one.
- **`rejected_Pushing.png`** — not a wind lean. A deep squat shoving something heavy
along the ground, standing up at the end. It reads as *moving a couch*. The
forward angle I hoped to borrow is a squat, not a lean.
- **`rejected_Leaning.png`** — a casual weight-on-the-back-foot lean, the "propped
against a bar" pose. Hands slack, no tension, near-identical across all five
frames. A person waiting for a bus, not one in a gale.
Neither is close enough to fix by retiming. Shipping either would have passed every
check we have — they load, they're metre-scale, their nodes survive — and been wrong
on screen. That's the bike rule: **the verify can't answer "is this the right shape."**
## 3. The useful technical result
In both strips the raw and stripped rows are **near-identical**, which is worth
knowing: these clips carry their lean in the **spine**, and spine rotation survives
`_rotOnly` intact. So a hand-authored posture (spine curl, shoulder hunch, arm up)
*would* survive the loader. What cannot survive is a lean authored into the **hips**
`_rotOnly` drops `Hips.quaternion` from every clip (`player.js:53`, applied `:239`).
Measured on the shipped pack: 17 clips, 195 channels each, **64 survive**.
Which is why the lean belongs in `player.sim.js` as a root pitch aimed at
`wind.dirAt(t)` — the wind's bearing swings (the change; site_02's venturi) and a
canned clip has one fixed lean axis. Same trick as the knockdown and `climbY`.
## Reproducing
```sh
# on ultra (the FBX libraries and the Mixamo login live there; JING5 has neither)
mkdir -p /tmp/shadeswind && cd /tmp/shadeswind
cp ~/Documents/mixamo-fetch/fetch.cjs .
printf 'Pushing\nLeaning\n' > wishlist.txt
SKIN=1 node fetch.cjs "X Bot" # SKIN=1: needs a mesh to judge a silhouette
/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
-P skinshot.py -- /tmp/shadeswind/out/Pushing.fbx Pushing
```
Notes for whoever next reaches for this pipeline:
- `fetch.cjs` reads its token from a **logged-in Brave on ultra via CDP** (port 9222).
Nobody types a password; the session is already there. It 429s after a handful of
exports — space them out.
- `resolveChar` defaults to `Grandma_mixamo`, which is not currently uploaded to the
account. **`X Bot`** is a Mixamo stock rig and works; for anim-only export the source
rig is irrelevant anyway, since `_rotOnly` keeps rotations only.
- Anim-only FBX (`SKIN` unset) has **no mesh** and renders nothing. Judging a
silhouette needs `SKIN=1`.
- Don't retarget onto `Hum_M_1_mixamo.fbx` to preview: its armature carries FBX's
0.01 unit scale and the clip's action doesn't, so the body collapses to ~3 cm.
That's the same "peds cannot round-trip through Blender" trap `build_player_anims.py`
documents.

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

View File

@ -0,0 +1,84 @@
"""Filmstrip a SKINNED Mixamo FBX, twice: as-authored, and as the game shows it
(Hips rotation cleared player.js:_rotOnly).
blender -b --factory-startup -P skinshot.py -- <skinned.fbx> <label>
Skinned export carries its own mesh at its own scale, so there's no cross-rig
retarget and no unit mismatch the reason the Hum_M_1 route collapsed.
Camera is fitted to the MEASURED bbox of the animated body rather than guessed,
because guessing framing is how the last three renders came out flat grey.
"""
import bpy, sys, os, math
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, LABEL = argv[0], argv[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=SRC)
arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
act = arm.animation_data.action if arm.animation_data else None
hips = next((b for b in arm.pose.bones if b.name.lower().endswith("hips")), None)
f0, f1 = (int(act.frame_range[0]), int(act.frame_range[1])) if act else (1, 1)
print(f" arm={arm.name} meshes={len(meshes)} act={act.name if act else None} frames={f0}..{f1} hips={hips.name if hips else None}")
sc = bpy.context.scene
N = 5
frames = [int(f0 + (f1 - f0) * i / (N - 1)) for i in range(N)] if f1 > f0 else [f0] * N
def world_pts(frame):
sc.frame_set(frame)
bpy.context.view_layer.update()
dg = bpy.context.evaluated_depsgraph_get()
pts = []
for m in meshes:
ev = m.evaluated_get(dg)
pts += [ev.matrix_world @ Vector(c) for c in ev.bound_box]
return pts
# --- measure the whole clip, then frame it once so all tiles share a scale.
allp = []
for fr in frames:
allp += world_pts(fr)
minx, maxx = min(p.x for p in allp), max(p.x for p in allp)
miny, maxy = min(p.y for p in allp), max(p.y for p in allp)
minz, maxz = min(p.z for p in allp), max(p.z for p in allp)
cx, cy, cz = (minx + maxx) / 2, (miny + maxy) / 2, (minz + maxz) / 2
span = max(maxz - minz, maxy - miny, 0.1)
print(f" clip bbox X[{minx:.2f},{maxx:.2f}] Y[{miny:.2f},{maxy:.2f}] Z[{minz:.2f},{maxz:.2f}] span={span:.2f}")
cam_d = bpy.data.cameras.new("cam"); cam_d.type = 'ORTHO'
cam_d.ortho_scale = span * 1.5
cam = bpy.data.objects.new("cam", cam_d); bpy.context.collection.objects.link(cam)
cam.rotation_mode = 'XYZ'
cam.location = (cx + span * 6, cy, cz)
cam.rotation_euler = (Vector((cx, cy, cz)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
sc.camera = cam
print(f" cam {tuple(round(v,2) for v in cam.location)} ortho={cam_d.ortho_scale:.2f}")
sc.render.engine = 'BLENDER_WORKBENCH'
sc.render.resolution_x, sc.render.resolution_y = 300, 460
sc.render.image_settings.file_format = 'PNG'
sc.display.shading.light = 'FLAT'
sc.display.shading.color_type = 'SINGLE'
sc.display.shading.single_color = (0.85, 0.32, 0.28)
if sc.world is None:
sc.world = bpy.data.worlds.new("W")
sc.world.color = (0.42, 0.45, 0.48)
for mode in ("raw", "stripped"):
for i, fr in enumerate(frames):
sc.frame_set(fr)
if mode == "stripped" and hips:
hips.rotation_mode = 'QUATERNION'
hips.rotation_quaternion = (1, 0, 0, 0)
bpy.context.view_layer.update()
sc.render.filepath = f"/tmp/shadeswind/tile_{LABEL}_{mode}_{i}.png"
bpy.ops.render.render(write_still=True)
print("DONE " + LABEL)

View File

@ -0,0 +1,39 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>garden bench — where does the protection go?</title>
<style>
body { background:#11161a; color:#dde5ea; font:13px/1.45 ui-monospace, Menlo, monospace; padding:18px; }
h1 { font-size:15px; color:#7ee0ff; margin:0 0 4px; }
p.sub { color:#8ba0ad; margin:0 0 14px; }
pre { white-space:pre; margin:0; }
.sep { color:#ffb1ab; }
</style>
</head>
<body>
<h1>GARDEN BENCH — Lane C, Sprint 13 gate 1</h1>
<p class="sub">Real yard, real rig, real skyfx exposure, main.js's exact drain. Held honest quad vs bare bed, both yards, five storms. This is a measurement, not a test — it has no opinion about what the numbers should be.</p>
<pre id="out">running… (builds two yards and flies ten storms twice — give it a few seconds)</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import { runBench } from './bench.js';
const el = document.getElementById('out');
const lines = [];
const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
try {
await runBench(log);
document.title = 'garden bench — done';
} catch (e) {
log('\n\nBENCH THREW: ' + (e && e.stack || e));
document.title = 'garden bench — THREW';
}
</script>
</body>
</html>

267
tools/garden_bench/bench.js Normal file
View File

@ -0,0 +1,267 @@
/**
* garden_bench WHERE DOES THE PROTECTION ACTUALLY GO? [Lane C, SPRINT13 gate 1]
*
* The QA pass found one variable sighted three times: the sim's garden outcome
* barely responds to whether a sail is up (sail DEAD at t=3 on the funnelled
* buster still finished 83%). A's GARDEN_DRAIN comment names the suspect and
* says the lever is the shadow GEOMETRY, not the constant. This bench is the
* measurement that has to come before any fix.
*
* It drives the REAL chain real site JSON, real world.js yard, real SailRig,
* real skyfx exposure helpers, main.js's exact drain arithmetic because a
* bench that reimplements the drain measures a copy and proves nothing (the
* lesson balance.test.js paid for twice: the fictional yard, and the missing
* camera).
*
* For each yard × storm it flies two conditions and reports where they differ:
* HELD an honest bed-covering quad on RATED steel, re-repaired every step
* so a corner failure can never contaminate a GEOMETRY measurement
* BARE no sail at all, the floor
* ...and buckets rain/hail shadow-over-bed by wind speed, which is the actual
* question: protection is not one number, it decays as the wind builds.
*
* Deliberately a browser tool: the scoring chain needs `document`. Run it at
* tools/garden_bench/bench.html; it prints a table and dumps JSON to console.
*/
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
// main.js's exact numbers. Duplicated ONLY so a divergence names itself; the
// bench asserts they still match main.js at the bottom of the report.
const GARDEN_DRAIN = 0.9;
const W_HAIL = 5.0;
const W_RAIN = 0.25;
export const STORMS = [
'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly',
'storm_03b_earlybuster', 'storm_02b_icenight',
];
/**
* The honest bed-covering line in each yard NOT the carport trap, NOT a rig
* that misses. backyard's is balance.test's COVER_QUAD (C's sweep, the only
* buyable one). site_02's is the site JSON's own _design note: the honest three
* posts + the gum tree, 58% cover at 32.6 .
*/
export const YARDS = [
{ site: 'backyard_01', quad: ['p1', 'p2', 'p3', 'p4'] },
{ site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] },
];
/**
* The real yard, read from world.js never a copied anchor table, and never a
* FROZEN one. Two landmines this builder now closes (SPRINT13, both caught by
* D replaying through the real UI):
* 1. The SITE def rides along because its wind block is half the yard's
* weather: the venturi lives HERE, not in any storm def. Measured without
* it, site_02 is a different, easier yard (third harness to learn this;
* windForSite is the fix and the story).
* 2. The anchors are world.anchors THEMSELVES. The old remap to a static
* `sway: () => pos` froze the gum tree a rig on a tree eats the tree's
* sway as dynamic load, and stripping it under-read every tr1 corner.
* The world is built on a re-pointable wind proxy (main.js-router style)
* so the sway closures sample whichever storm is currently flying.
*/
async function buildYard(siteId) {
const scene = new THREE.Scene();
let current = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const proxy = {
sample: (p, t, o) => current.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => current.speedAt(p, t),
rainAt: (t) => current.rainAt(t),
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
gustTelegraph: (t) => current.gustTelegraph(t),
eventsBetween: (a, b) => current.eventsBetween(a, b),
setSheltersFromTrees() {},
get seed() { return current.seed ?? 1; },
get def() { return current.def ?? {}; },
};
const siteDef = await loadSite(siteId);
const world = createWorld(scene, { wind: proxy, site: siteDef });
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
const anchors = world.anchors;
return {
anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id),
sunDir: world.sunDir, heightAt: world.heightAt,
siteDef,
use: (w) => { current = w; },
};
}
/**
* Fly one condition and record the geometry as a time series.
* @param {'held'|'bare'} mode
*/
async function fly(yard, stormName, mode) {
const def = await loadStorm(stormName);
// windForSite, NOT createWind + storm-def venturi. The old line here read
// `def.wind.venturi` off the STORM def, where a venturi never lives — it
// LOOKED right and never fired, so every site_02 number this bench produced
// before 2026-07-18 was flown with the funnel OFF (D caught it replaying the
// $80 line through the real UI: 91.5 FULL became 39.8 TATTERED). Same bug
// B's site_audit shipped in Sprint 11. The helper carries the story.
const wind = windForSite(def, yard.siteDef, yard.anchors);
yard.use(wind); // point the yard's tree-sway closures at this flight's wind
let rig = null;
if (mode === 'held') {
const s = new RiggingSession({ anchors: yard.anchors, budget: 9999 });
for (const id of yard.quad) {
const r = s.rig(id);
if (!r.ok) return { skipped: `cannot rig ${id}: ${r.reason ?? 'refused'}` };
}
// RATED on every corner: this is a geometry bench, so buy the strongest
// steel in the game and ignore the $80 budget on purpose. Money is B's
// question; this one is "where does the shadow land".
for (const id of yard.quad) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} — flying hardware it did not buy`); }
s.setTension(1.0);
rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
}
// NO calm settle (correction, 2026-07-18): in the real game the rig does not
// exist before commit, and commit → attach → advance() → storm land in one
// keypress (sail.js attach, B's clock-reset comment). The attach transient
// rides the storm's own calm opening exactly as it does for a player — and
// because SailRig samples wind at its INTERNAL clock, a 12 s settle here
// skewed every storm sample 12 s off the authored curve.
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
camera.position.set(0, 2, 8);
const sky = createSkyFx({ wind, night: true, camera });
const bed = yard.bed;
const probe = new THREE.Vector3(bed.x, 1.7, bed.z);
let hp = 100, byHail = 0, byRain = 0, repairs = 0;
const rows = [];
const steps = Math.round(def.duration / FIXED_DT);
for (let i = 0; i < steps; i++) {
const t = i * FIXED_DT;
if (rig) {
rig.step(FIXED_DT, wind, t);
// HELD means held. A corner that lets go turns this into a hardware
// measurement instead of a geometry one, so put it straight back.
for (let k = 0; k < rig.corners.length; k++) {
if (rig.corners[k].broken) { rig.repairCorner(k, RATED); repairs++; }
}
}
sky.step(FIXED_DT, t, rig ? { sail: rig } : {});
const hailExp = sky.gardenHailExposure(bed, t);
const rainExp = sky.gardenExposure(bed, t);
// main.js's exact drain
const dHail = W_HAIL * hailExp * GARDEN_DRAIN * FIXED_DT;
const dRain = W_RAIN * rainExp * GARDEN_DRAIN * FIXED_DT;
const total = Math.min(hp, dHail + dRain);
if (total > 0) {
const scale = total / (dHail + dRain);
byHail += dHail * scale; byRain += dRain * scale;
hp -= total;
}
// sample the geometry a few times a second — the grids only rebuild at 10 Hz
if (i % 6 === 0) {
rows.push({
t,
speed: wind.speedAt(probe, t),
rainShadow: sky.rainShadowOver(bed),
hailShadow: sky.hailShadowOver(bed),
rainExp, hailExp,
hail: sky.hailAmount,
});
}
}
sky.dispose?.();
return {
hp: Math.round(hp * 10) / 10,
byHail: Math.round(byHail * 10) / 10,
byRain: Math.round(byRain * 10) / 10,
repairs,
rows,
// the SUN coverage — reported for context only. It is emphatically not the
// rain/hail geometry, and the gap between them is half of what this bench
// exists to show.
cover: rig ? rig.coverageOver(bed, yard.sunDir, yard.heightAt) : 0,
};
}
/** Bucket a time series by wind speed — protection is not one number. */
function byWind(rows) {
const buckets = [[0, 10], [10, 15], [15, 20], [20, 25], [25, 99]];
return buckets.map(([lo, hi]) => {
const r = rows.filter((x) => x.speed >= lo && x.speed < hi);
if (!r.length) return { band: `${lo}-${hi}`, n: 0 };
const mean = (f) => r.reduce((a, b) => a + f(b), 0) / r.length;
return {
band: `${lo}-${hi}`,
n: r.length,
kmh: `${Math.round(lo * 3.6)}-${Math.round(hi * 3.6)}`,
rainShadow: mean((x) => x.rainShadow),
hailShadow: mean((x) => x.hailShadow),
};
}).filter((b) => b.n > 0);
}
const f2 = (v) => (v == null ? ' — ' : v.toFixed(2).padStart(6));
const f1 = (v) => (v == null ? ' — ' : v.toFixed(1).padStart(5));
export async function runBench(log) {
const out = [];
log('# GARDEN BENCH — where does the protection actually go?');
log('# HELD = honest bed-covering quad, RATED steel, re-repaired every step (geometry only)');
log('# BARE = no sail. Drain is main.js exact: hail×5.0 + rain×0.25, ×0.9.\n');
for (const y of YARDS) {
const yard = await buildYard(y.site);
// Print the funnel in the header, B's audit.html pattern: a reader must be
// able to SEE which wind these numbers were flown in.
const vl = yard.siteDef.wind?.venturi ?? [];
const vtxt = vl.length
? vl.map((v) => `throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ')
: 'none';
log(`\n## ${y.site} bed ${yard.bed.w}×${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')} venturi: ${vtxt}`);
for (const storm of STORMS) {
const held = await fly({ ...yard, quad: y.quad }, storm, 'held');
if (held.skipped) { log(` ${storm}: SKIPPED — ${held.skipped}`); continue; }
const bare = await fly({ ...yard, quad: y.quad }, storm, 'bare');
const sep = bare.hp === 0 && held.hp === 0 ? 0 : held.hp - bare.hp;
out.push({ site: y.site, storm, held, bare, sep });
log(`\n ### ${storm} cover ${(held.cover * 100).toFixed(0)}% repairs ${held.repairs}`);
log(` GARDEN HP held ${f1(held.hp)} bare ${f1(bare.hp)} SEPARATION ${f1(sep)}`);
log(` held damage: hail ${f1(held.byHail)} rain ${f1(held.byRain)}`);
log(` bare damage: hail ${f1(bare.byHail)} rain ${f1(bare.byRain)}`);
log(' wind band km/h rainShadow(held) hailShadow(held)');
for (const b of byWind(held.rows)) {
log(` ${b.band.padStart(6)} m/s ${b.kmh.padStart(8)} ${f2(b.rainShadow)} ${f2(b.hailShadow)}`);
}
}
}
log('\n\n# THE HEADLINE');
log('# site storm held bare sep');
for (const r of out) {
log(`# ${r.site.padEnd(26)} ${r.storm.padEnd(22)} ${f1(r.held.hp)} ${f1(r.bare.hp)} ${f1(r.sep)}`);
}
console.log('GARDEN_BENCH_JSON', JSON.stringify(out.map((r) => ({
site: r.site, storm: r.storm, held: r.held.hp, bare: r.bare.hp, sep: r.sep,
heldHail: r.held.byHail, heldRain: r.held.byRain,
bareHail: r.bare.byHail, bareRain: r.bare.byRain,
cover: r.held.cover,
}))));
return out;
}

View File

@ -0,0 +1,99 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>shadow probe</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { RainShadow } from '../../web/world/js/skyfx.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [,, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// TWO HARNESSES, ONE QUANTITY. rig.coverageOver(rect, straight-up sun) is the
// sail's true vertical projection over the bed, computed by raycast. And
// RainShadow.fractionOver(rect) with straight-down rain is the SAME quantity,
// computed by rasterising the sail's triangles into a grid. They must agree.
// If they don't, the shadow grid — which is what scores every rig — is lying.
try {
const scene = new THREE.Scene();
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
if (world.dress) { try { await world.dress(); } catch {} }
const anchors = world.anchors.map((a) => { const pos={x:a.pos.x,y:a.pos.y,z:a.pos.z};
return { id:a.id, type:a.type, pos, sway:()=>pos }; });
const bed = world.gardenBed;
log(`bed: centre (${bed.x}, ${bed.z}) size ${bed.w} x ${bed.d}\n`);
const s = new RiggingSession({ anchors, budget: 9999 });
for (const id of ['p1','p2','p3','p4']) s.rig(id);
for (const id of ['p1','p2','p3','p4']) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason}`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
// settle in dead air so the cloth is in steady state and nothing is moving
for (let i = 0; i < Math.round(12/FIXED_DT); i++) rig.step(FIXED_DT, calm, 0);
const corners = ['p1','p2','p3','p4'].map((id) => anchors.find((a)=>a.id===id));
log('quad corners:');
for (const c of corners) log(` ${c.id.padEnd(3)} (${c.pos.x.toFixed(2)}, ${c.pos.y.toFixed(2)}, ${c.pos.z.toFixed(2)})`);
// where is the cloth, really?
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9,minY=1e9,maxY=-1e9;
for (let i=0;i<rig.pos.length;i+=3){
minX=Math.min(minX,rig.pos[i]); maxX=Math.max(maxX,rig.pos[i]);
minY=Math.min(minY,rig.pos[i+1]); maxY=Math.max(maxY,rig.pos[i+1]);
minZ=Math.min(minZ,rig.pos[i+2]); maxZ=Math.max(maxZ,rig.pos[i+2]);
}
log(`\ncloth bbox: x ${minX.toFixed(2)}..${maxX.toFixed(2)} y ${minY.toFixed(2)}..${maxY.toFixed(2)} z ${minZ.toFixed(2)}..${maxZ.toFixed(2)}`);
log(`bed spans: x ${(bed.x-bed.w/2).toFixed(2)}..${(bed.x+bed.w/2).toFixed(2)} z ${(bed.z-bed.d/2).toFixed(2)}..${(bed.z+bed.d/2).toFixed(2)}`);
log(`tris: ${rig.tris.length/3} nodes: ${rig.pos.length/3}`);
// harness 1: raycast toward a sun straight overhead
const cov = rig.coverageOver(bed, { x:0, y:1, z:0 }, null);
// harness 2: rasterise down a straight-down rain vector
const sh = new RainShadow({ groundY: 0 });
sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
log(`\n--- THE SAME QUANTITY, TWO WAYS (vertical) ---`);
log(` rig.coverageOver(bed, straight-up sun) = ${cov.toFixed(4)}`);
log(` RainShadow.fractionOver(bed), rain down = ${frac.toFixed(4)}`);
log(` live=${sh.live} difference = ${Math.abs(cov-frac).toFixed(4)}`);
// how much of the grid did the rasteriser actually fill, vs the cloth's footprint?
let filled = 0; for (let i=0;i<sh.ceil.length;i++) if (sh.ceil[i]>0) filled++;
const cellA = (sh.half*2/sh.n)**2;
log(`\n grid cells filled: ${filled} => ${(filled*cellA).toFixed(1)} m2 of ground shadowed`);
log(` cloth footprint bbox area: ${((maxX-minX)*(maxZ-minZ)).toFixed(1)} m2`);
// sample the bed on a fine lattice both ways to see WHERE they differ
log(`\n--- bed lattice: 1 = shadowed, . = open (rows = z, cols = x) ---`);
for (let j=0;j<9;j++){
let rowS='', rowC='';
for (let i=0;i<13;i++){
const x = bed.x + ((i+0.5)/13 - 0.5)*bed.w;
const z = bed.z + ((j+0.5)/9 - 0.5)*bed.d;
const c = sh._idx(x,z);
rowS += (c>=0 && sh.ceil[c]>0) ? '1' : '.';
rowC += rig.coverageOver({x, z, w:0.001, d:0.001}, {x:0,y:1,z:0}, null) > 0.5 ? '1' : '.';
}
log(` grid ${rowS} raycast ${rowC}`);
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe done';
</script>
</body></html>

View File

@ -0,0 +1,79 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>shadow probe 2 — synthetic</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { RainShadow } from '../../web/world/js/skyfx.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [,, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// ISOLATE THE RASTERISER FROM THE LEVEL.
// probe.html showed the real yard's best quad sits over only 25% of the bed, so
// its shadow numbers can't tell a rasteriser bug from an anchor-placement fact.
// So: a SYNTHETIC square of anchors dead over the bed, at 4 m. This sail
// unambiguously covers the whole bed. Grid and raycast must BOTH read ~1.0.
// Anything less is the shadow model losing cloth it is standing under.
try {
const bed = { x: 0, z: 0, w: 6, d: 4 };
const mk = (id, x, z) => { const pos = { x, y: 4, z }; return { id, type: 'post', pos, sway: () => pos }; };
// a generous 10x8 square around a 6x4 bed — no edge effects, no excuses
const anchors = [mk('s1', -5, -4), mk('s2', 5, -4), mk('s3', 5, 4), mk('s4', -5, 4)];
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
for (const gridN of [10, 16]) {
const s = new RiggingSession({ anchors, budget: 9999 });
for (const a of anchors) s.rig(a.id);
for (const a of anchors) { const r = s.setHardware(a.id, RATED);
if (!r.ok) throw new Error(`setHardware ${a.id} refused: ${r.reason}`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN }));
for (let i = 0; i < Math.round(12/FIXED_DT); i++) rig.step(FIXED_DT, calm, 0);
let minY=1e9,maxY=-1e9;
for (let i=1;i<rig.pos.length;i+=3){ minY=Math.min(minY,rig.pos[i]); maxY=Math.max(maxY,rig.pos[i]); }
const cov = rig.coverageOver(bed, { x:0, y:1, z:0 }, null);
const sh = new RainShadow({ groundY: 0 });
sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
log(`\n=== gridN ${gridN} (${rig.tris.length/3} tris, ${rig.pos.length/3} nodes) cloth y ${minY.toFixed(2)}..${maxY.toFixed(2)} ===`);
log(` a 10x4 sail sitting dead over a 6x4 bed. Truth for both numbers is 1.00.`);
log(` rig.coverageOver(bed, straight up) = ${cov.toFixed(4)} ${cov > 0.99 ? 'ok' : '<-- LOSES CLOTH'}`);
log(` RainShadow.fractionOver(bed), down = ${frac.toFixed(4)} ${frac > 0.99 ? 'ok' : '<-- LOSES CLOTH'}`);
// where are the holes? print the grid cells across the bed's footprint
log(' grid cells over the bed (1 = shadowed):');
for (let j=0;j<7;j++){
let row='';
for (let i=0;i<15;i++){
const x = bed.x + ((i+0.5)/15 - 0.5)*bed.w;
const z = bed.z + ((j+0.5)/7 - 0.5)*bed.d;
const c = sh._idx(x,z);
row += (c>=0 && sh.ceil[c]>0) ? '1' : '.';
}
log(` ${row}`);
}
let filled=0; for (let i=0;i<sh.ceil.length;i++) if (sh.ceil[i]>0) filled++;
const cellA = (sh.half*2/sh.n)**2;
log(` cells filled ${filled} => ${(filled*cellA).toFixed(1)} m2 shadowed; the sail is 10x8 = 80 m2`);
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe2 done';
</script>
</body></html>

View File

@ -0,0 +1,135 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>probe3 — does the covering line hold?</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE DECIDING QUESTION.
// sweep.html found quads that cover 100% of the bed in BOTH yards, while the
// canonical line covers 25%. B's Sprint-9 sweep says every bed-covering quad has
// a corner over 6.5 kN — unholdable even on RATED, the best steel in the game —
// and that p1..p4 is the ONLY buyable line. If that's true, then "coverage" and
// "holdability" are anti-correlated by construction, the affordable rig can only
// ever shade a quarter of the bed, and NO shadow-model change can make the
// garden respond to rigging. That is a very different bug from the one the
// sprint chartered, so it has to be measured, not inherited.
//
// Fly the covering lines. Report peak corner load per corner and the garden.
const GARDEN_DRAIN = 0.9, W_HAIL = 5.0, W_RAIN = 0.25;
async function fly(world, ids, stormName, hw, siteDef, use) {
const def = await loadStorm(stormName);
const anchors = world.anchors; // REAL anchors: trees sway, hints ride along
const bed = world.gardenBed;
// windForSite: the SITE's venturi + shelters, as main.js wires them. Before
// 2026-07-18 this probe (a) never set the venturi — every site_02 row was
// flown with the funnel OFF — and (b) remapped anchors to a static
// `sway: () => pos`, freezing the gum tree and dropping ratingHint. See
// probe4's correction note and the THREADS postscript; backyard post-only
// lines (p1..p4) were unaffected by either, which is why they matched D's
// UI replays to the decimal while the tr1 lines lied.
const wind = windForSite(def, siteDef, anchors);
use(wind);
const s = new RiggingSession({ anchors, budget: 9999 });
for (const id of ids) if (!s.rig(id).ok) return null;
for (const id of ids) { const r = s.setHardware(id, hw);
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} — flying hardware it did not buy`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
// no calm settle: commit → attach → storm is one keypress in the real game
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
camera.position.set(0, 2, 8);
const sky = createSkyFx({ wind, night: true, camera });
const peak = ids.map(() => 0);
let hp = 100, lost = 0, hsum = 0, hn = 0, brokeAt = null;
const steps = Math.round(def.duration / FIXED_DT);
for (let i=0;i<steps;i++){
const t = i * FIXED_DT;
rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
for (let k=0;k<rig.corners.length;k++) if ((rig.corners[k].load||0) > peak[k]) peak[k] = rig.corners[k].load;
if (brokeAt === null && rig.corners.some((c)=>c.broken)) brokeAt = t;
// the hail shadow WHILE it is hailing is the only one that scores
if (sky.hailAmount > 0) { hsum += sky.hailShadowOver(bed); hn++; }
const ex = W_HAIL*sky.gardenHailExposure(bed,t) + W_RAIN*sky.gardenExposure(bed,t);
if (ex > 0) hp = Math.max(0, hp - GARDEN_DRAIN*ex*FIXED_DT);
}
lost = rig.corners.filter((c)=>c.broken).length;
sky.dispose?.();
// Peaks are indexed by rig.corners — the RING order commit() reordered the
// picks into — so label them with corners[k].anchorId, NOT the input order.
// (Found at re-verify: p1..p4 flies as p4,p2,p3,p1; an input-order label
// attributes each corner's load to the wrong anchor.)
return { hp: Math.round(hp*10)/10, lost, peak: peak.map((p)=>p/1000),
cornerIds: rig.corners.map((c)=>c.anchorId),
hailShadow: hn ? hsum/hn : 0, brokeAt };
}
try {
for (const [siteId, lineSet] of [
['backyard_01', { cover100: ['h1','t1','t2b','p2'], cover100b: ['h1','h3','p1','p2'], canonical: ['p1','p2','p3','p4'] }],
['site_02_corner_block', { posts: ['q1','q2','q3','q4'], cover100: ['tr1','q1','q3','q4'], canonical: ['q1','q2','q3','tr1'] }],
]) {
const scene = new THREE.Scene();
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
const siteDef = await loadSite(siteId);
// re-pointable wind, main.js-router style: sway closures capture the proxy
let current = calm;
const proxy = {
sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()),
speedAt: (p,t) => current.speedAt(p, t),
rainAt: (t) => current.rainAt(t),
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
gustTelegraph: (t) => current.gustTelegraph(t),
eventsBetween: (a,b) => current.eventsBetween(a, b),
setSheltersFromTrees() {},
get seed() { return current.seed ?? 1; },
get def() { return current.def ?? {}; },
};
const use = (w) => { current = w; };
const world = createWorld(scene, { wind: proxy, site: siteDef });
if (world.dress) { try { await world.dress(); } catch {} }
const bed = world.gardenBed;
const anchors = world.anchors;
const vl = siteDef.wind?.venturi ?? [];
const vtxt = vl.length ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ') : 'none';
log(`\n\n######## ${siteId} (RATED = 6.5 kN is the best steel in the game, $30/corner) venturi: ${vtxt}`);
for (const [label, ids] of Object.entries(lineSet)) {
for (const storm of ['storm_02_wildnight', 'storm_02b_icenight', 'storm_03b_earlybuster']) {
const r = await fly(world, ids, storm, RATED, siteDef, use);
if (!r) { log(` ${label} ${ids.join('+')}: REFUSED`); continue; }
const over = r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' ');
const bare = { storm_02_wildnight: 35.7, storm_02b_icenight: 0, storm_03b_earlybuster: 82.6 }[storm];
log(` ${label.padEnd(10)} ${ids.join('+').padEnd(18)} ${storm.padEnd(22)} hp ${String(r.hp).padStart(5)}`
+ ` (bare ${bare}, sep ${(r.hp-bare).toFixed(1).padStart(5)}) lost ${r.lost}/4`
+ `${r.brokeAt!==null?` @t=${r.brokeAt.toFixed(0)}`:' '} hailShadow ${r.hailShadow.toFixed(2)} peak kN: ${over}`);
}
log('');
}
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe3 done';
</script>
</body></html>

View File

@ -0,0 +1,153 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>probe4 — the $80 line that saves the garden</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE PAYOFF — and the correction (2026-07-18).
// The first run of this probe read the $80 line tr1+q1+q3+q4 at 91.5 FULL on
// the wild night and called it the headline. TWO harness landmines, both mine:
// 1. No venturi — the funnel lives in the SITE def, and this probe never set
// it (bench.js read it off the STORM def, where it never lives). Third
// harness to make B's Sprint-11 mistake.
// 2. Static anchors — the remap `sway: () => pos` stripped tr1's TREE sway,
// the dynamic load a rig on a gum tree has to eat (DESIGN.md's whole
// warning about tree anchors). The backyard bench numbers matched D's UI
// replays to the decimal BECAUSE p1..p4 are all posts; any tr1 line was
// flown against a tree that never moved.
// With both fixed the probe agrees with D's live UI replays in direction and
// closely in number (buster peaks within ~15%); on the wild night the line's
// cheapest corner rides AT its rating (q4 carabiner ~1.2 kN) — a knife edge
// the real game falls off (D: q3+q4 break t≈45, 39.8 TATTERED). Where this
// probe and D's UI disagree on which side of that edge a corner lands, D's
// UI number is canonical. What survives: the line holds the BUSTER — site_02's
// actual shipped night — with room (D live: 97.9 FULL, 0/4).
async function fly(world, plan, stormName, siteDef, use) {
const def = await loadStorm(stormName);
const anchors = world.anchors; // REAL anchors — tr1 sways
const bed = world.gardenBed;
const wind = windForSite(def, siteDef, anchors);
use(wind); // point the world's sway closures at the flight wind
// THE REAL SHOP, at the REAL budget. Every refusal is loud.
const s = new RiggingSession({ anchors }); // budget = START_BUDGET
for (const [id] of plan) {
const r = s.rig(id);
if (!r.ok) return { err: `rig ${id}: ${r.reason}` };
}
for (const [id, hw] of plan) {
const r = s.setHardware(id, hw);
if (!r.ok) return { err: `setHardware ${id} ${hw.name}: ${r.reason}` };
}
s.setTension(1.0);
const spent = START_BUDGET - s.budget;
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
// NO calm settle: in the real game the rig does not exist until commit, and
// commit → attach → advance() → storm land in one keypress (B's clock-reset
// comment, sail.js attach). The attach transient rides the storm's own calm
// opening, exactly as it does for a player.
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
camera.position.set(0, 2, 8);
const sky = createSkyFx({ wind, night: true, camera });
const peak = plan.map(() => 0);
let hp = 100, hsum = 0, hn = 0;
const steps = Math.round(def.duration / FIXED_DT);
for (let i=0;i<steps;i++){
const t = i * FIXED_DT;
rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
for (let k=0;k<rig.corners.length;k++) if ((rig.corners[k].load||0) > peak[k]) peak[k] = rig.corners[k].load;
if (sky.hailAmount > 0) { hsum += sky.hailShadowOver(bed); hn++; }
const ex = 5.0*sky.gardenHailExposure(bed,t) + 0.25*sky.gardenExposure(bed,t);
if (ex > 0) hp = Math.max(0, hp - 0.9*ex*FIXED_DT);
}
const lost = rig.corners.filter((c)=>c.broken).length;
sky.dispose?.();
// Peaks are indexed by rig.corners — the RING order commit() reordered the
// picks into — so label them with corners[k].anchorId, NOT the plan order.
// (Found at re-verify: the $80 line flies as q1,tr1,q3,q4 — a plan-order
// label swaps the two RATED corners' loads. q3/q4 were unaffected.)
return { hp: Math.round(hp*10)/10, lost, spent, peak: peak.map((p)=>p/1000),
cornerIds: rig.corners.map((c)=>c.anchorId), hailShadow: hn?hsum/hn:0 };
}
// main.js's own win rule
const WIN = (hp, lost) => hp >= 50 && lost < 2;
const state = (hp) => hp > 66 ? 'FULL' : hp > 33 ? 'tattered' : 'DEAD';
try {
const scene = new THREE.Scene();
// A re-pointable wind, the way main.js's router serves world.js: the world's
// tree-sway closures capture THIS object, and fly() re-points it per storm.
// Building the world on a dead stub and remapping anchors to static
// `sway: () => pos` was landmine #2 — it froze the gum tree.
let current = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, eventsBetween:()=>[] };
const proxy = {
sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()),
speedAt: (p,t) => current.speedAt(p, t),
rainAt: (t) => current.rainAt(t),
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
gustTelegraph: (t) => current.gustTelegraph(t),
eventsBetween: (a,b) => current.eventsBetween(a, b),
setSheltersFromTrees() {},
get seed() { return current.seed ?? 1; },
get def() { return current.def ?? {}; },
};
const use = (w) => { current = w; };
const siteDef = await loadSite('site_02_corner_block');
const world = createWorld(scene, { wind: proxy, site: siteDef });
await world.dress(); // NOT optional: undressed, the carport trap does not exist
const bed = world.gardenBed;
const PLANS = {
'THE $80 LINE tr1=rated q1=rated q3=shackle q4=carabiner':
[['tr1',RATED],['q1',RATED],['q3',SHACKLE],['q4',CARABINER]],
'all-shackle tr1+q1+q3+q4 ($60)':
[['tr1',SHACKLE],['q1',SHACKLE],['q3',SHACKLE],['q4',SHACKLE]],
'canonical q1+q2+q3+tr1, best steel $80 can buy':
[['q1',RATED],['q2',SHACKLE],['q3',CARABINER],['tr1',RATED]],
};
const vl = siteDef.wind?.venturi ?? [];
const vtxt = vl.length
? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ') : 'none';
log(`site_02_corner_block budget $${START_BUDGET} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`);
// bare-bed refs are funnel-independent (no sail = exposure is the storm's own
// rain/hail timeline, wind never enters it); D confirmed 82.6 live on the buster
log(`bare bed for reference: wildnight 35.7 (DEAD) icenight 0.0 (DEAD) buster 82.6 (FULL)\n`);
for (const [label, plan] of Object.entries(PLANS)) {
log(`\n## ${label}`);
for (const storm of ['storm_02_wildnight','storm_02b_icenight','storm_03b_earlybuster']) {
const r = await fly(world, plan, storm, siteDef, use);
if (r.err) { log(` ${storm.padEnd(22)} SHOP REFUSED: ${r.err}`); continue; }
const bare = { storm_02_wildnight:35.7, storm_02b_icenight:0, storm_03b_earlybuster:82.6 }[storm];
log(` ${storm.padEnd(22)} spent $${String(r.spent).padStart(3)} hp ${String(r.hp).padStart(5)} ${state(r.hp).padEnd(8)}`
+ ` (bare ${String(bare).padStart(4)} ${state(bare)}) sep ${(r.hp-bare).toFixed(1).padStart(5)}`
+ ` lost ${r.lost}/4 hailShadow ${r.hailShadow.toFixed(2)} ${WIN(r.hp,r.lost)?'WIN ':'LOSS'}`
+ ` peak kN: ${r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' ')}`);
}
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe4 done';
</script>
</body></html>

View File

@ -0,0 +1,132 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>quad sweep — can ANY line cover the bed?</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { RainShadow } from '../../web/world/js/skyfx.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [,, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE QUESTION probe.html forces: the yard's canonical "best bed-covering quad"
// sits over 25% of the bed vertically, and hail falls vertically — so the sail
// can never stop more than a quarter of what kills the garden. Is that the
// LEVEL's ceiling, or did the canonical line just pick badly?
//
// Sweep every 4-anchor combination. Stage 1 is a cheap polygon-overlap proxy
// (the cloth lives roughly inside its anchors' hull), stage 2 flies the real
// cloth + real shadow grid for the best candidates. Cheap-then-real, because
// C(n,4) settles would take an hour and the proxy only has to RANK.
const clip = (poly, rect) => {
// SutherlandHodgman against the bed rect, then shoelace. Exact for convex.
const [x0, x1] = [rect.x - rect.w/2, rect.x + rect.w/2];
const [z0, z1] = [rect.z - rect.d/2, rect.z + rect.d/2];
const edges = [
(p) => p.x >= x0, (p) => p.x <= x1, (p) => p.z >= z0, (p) => p.z <= z1,
];
const isect = [
(a, b) => ({ x: x0, z: a.z + (b.z - a.z) * (x0 - a.x) / (b.x - a.x) }),
(a, b) => ({ x: x1, z: a.z + (b.z - a.z) * (x1 - a.x) / (b.x - a.x) }),
(a, b) => ({ x: a.x + (b.x - a.x) * (z0 - a.z) / (b.z - a.z), z: z0 }),
(a, b) => ({ x: a.x + (b.x - a.x) * (z1 - a.z) / (b.z - a.z), z: z1 }),
];
let out = poly;
for (let e = 0; e < 4; e++) {
const inp = out; out = [];
for (let i = 0; i < inp.length; i++) {
const a = inp[i], b = inp[(i + 1) % inp.length];
const ain = edges[e](a), bin = edges[e](b);
if (ain && bin) out.push(b);
else if (ain && !bin) out.push(isect[e](a, b));
else if (!ain && bin) { out.push(isect[e](a, b)); out.push(b); }
}
if (!out.length) return 0;
}
let s = 0;
for (let i = 0; i < out.length; i++) {
const a = out[i], b = out[(i + 1) % out.length];
s += a.x * b.z - b.x * a.z;
}
return Math.abs(s) / 2;
};
// convex hull (the cloth spans its anchors' hull, so order the quad sanely)
const hull = (pts) => {
const p = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
const lo = []; for (const q of p) { while (lo.length >= 2 && cross(lo[lo.length-2], lo[lo.length-1], q) <= 0) lo.pop(); lo.push(q); }
const up = []; for (const q of p.slice().reverse()) { while (up.length >= 2 && cross(up[up.length-2], up[up.length-1], q) <= 0) up.pop(); up.push(q); }
lo.pop(); up.pop(); return lo.concat(up);
};
try {
for (const siteId of ['backyard_01', 'site_02_corner_block']) {
const scene = new THREE.Scene();
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) });
if (world.dress) { try { await world.dress(); } catch {} }
const anchors = world.anchors.map((a) => { const pos={x:a.pos.x,y:a.pos.y,z:a.pos.z};
return { id:a.id, type:a.type, pos, sway:()=>pos }; });
const bed = world.gardenBed;
const bedArea = bed.w * bed.d;
log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) = ${bedArea} m2 ${anchors.length} anchors`);
// stage 1: rank every 4-combo by hull-over-bed overlap
const cands = [];
const n = anchors.length;
for (let a=0;a<n;a++) for (let b=a+1;b<n;b++) for (let c=b+1;c<n;c++) for (let d=c+1;d<n;d++) {
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
const h = hull(q.map((x) => ({ x: x.pos.x, z: x.pos.z })));
if (h.length < 3) continue;
const over = clip(h, bed) / bedArea;
cands.push({ ids: q.map((x)=>x.id), over });
}
cands.sort((p, q) => q.over - p.over);
log(` swept ${cands.length} quads. Top 8 by hull-over-bed (proxy):`);
for (const c of cands.slice(0, 8)) log(` ${c.ids.join('+').padEnd(24)} hull covers ${(c.over*100).toFixed(0)}% of bed`);
// stage 2: fly the real cloth for the best few + the canonical line
const canonical = siteId === 'backyard_01' ? ['p1','p2','p3','p4'] : ['q1','q2','q3','tr1'];
const tryList = [];
for (const c of cands.slice(0, 5)) tryList.push(c.ids);
if (!tryList.some((t) => t.join()===canonical.join())) tryList.push(canonical);
log(`\n REAL cloth + REAL shadow grid (settled 12 s, dead air, vertical rain):`);
log(` line hull% coverageOver(up) RainShadow.fractionOver`);
for (const ids of tryList) {
const s = new RiggingSession({ anchors, budget: 9999 });
let ok = true;
for (const id of ids) if (!s.rig(id).ok) { ok = false; break; }
if (!ok) { log(` ${ids.join('+').padEnd(24)} REFUSED by the shop`); continue; }
for (const id of ids) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} RATED refused: ${r.reason} — the bench is flying hardware it did not buy`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
for (let i=0;i<Math.round(12/FIXED_DT);i++) rig.step(FIXED_DT, calm, 0);
const cov = rig.coverageOver(bed, {x:0,y:1,z:0}, null);
const sh = new RainShadow({ groundY: 0 }); sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
const hp = hull(ids.map((id)=>{const a=anchors.find((z)=>z.id===id);return {x:a.pos.x,z:a.pos.z};}));
const over = clip(hp, bed)/bedArea;
const mark = ids.join()===canonical.join() ? ' <-- the canonical line' : '';
log(` ${ids.join('+').padEnd(24)} ${(over*100).toFixed(0).padStart(4)}% ${cov.toFixed(3).padStart(12)} ${frac.toFixed(3).padStart(18)}${mark}`);
}
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'sweep done';
</script>
</body></html>

View File

@ -0,0 +1,125 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>sweep2 — hold AND cover?</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { RiggingSession } from '../../web/world/js/rigging.js';
import { SailRig } from '../../web/world/js/sail.js';
import { RainShadow } from '../../web/world/js/skyfx.js';
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE PARETO QUESTION, and the whole of gate 1.
//
// probe2 proved the shadow model is sound (a sail over the bed reads 1.000 by
// raycast AND by the grid). probe3 found the variable: _checkFailure compares
// load against hw.rating * anchor.ratingHint, and the HOUSE fascia's hint is
// 0.35 — so a $30 RATED shackle is worth 2.3 kN on the house and 6.5 kN on a
// post. Every quad that covers the bed hangs off the house.
//
// So: sweep every 4-anchor line on the BEST steel in the game, settle it the way
// prep does, and ask both questions at once — does it HOLD, and does it COVER?
// A quad that covers 100% while tearing itself apart in prep covers nothing.
// This is the table the garden's whole design rests on and nobody has ever
// printed it.
try {
for (const siteId of ['backyard_01', 'site_02_corner_block']) {
const scene = new THREE.Scene();
const zero = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
const siteDef = await loadSite(siteId);
// re-pointable wind, main.js-router style: the anchors are world.anchors
// THEMSELVES, so tree sway is live — the old static remap froze the gum
// tree and under-read every tr1 corner (correction, 2026-07-18).
let current = zero;
const proxy = {
sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()),
speedAt: (p,t) => current.speedAt(p, t),
rainAt: (t) => current.rainAt(t),
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
gustTelegraph: (t) => current.gustTelegraph(t),
eventsBetween: (a,b) => current.eventsBetween(a, b),
setSheltersFromTrees() {},
get seed() { return current.seed ?? 1; },
get def() { return current.def ?? {}; },
};
const world = createWorld(scene, { wind: proxy, site: siteDef });
if (world.dress) { try { await world.dress(); } catch {} }
const anchors = world.anchors;
const bed = world.gardenBed;
const vl = siteDef.wind?.venturi ?? [];
const vtxt = vl.length ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ') : 'none';
log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`);
log(` anchors and what the BEST steel ($30 RATED, 6.5 kN) is actually worth on them:`);
for (const a of anchors) {
log(` ${a.id.padEnd(5)} ${String(a.type).padEnd(8)} hint ${(a.ratingHint ?? 1).toFixed(2)} -> ${((a.ratingHint ?? 1) * 6.5).toFixed(2)} kN`);
}
// prep's own wind: main.js hands the rig the calm day outside a storm.
// windForSite so the SITE's venturi flies too — before 2026-07-18 this
// sweep settled site_02's quads with the funnel OFF (the third harness to
// make B's Sprint-11 mistake; see windForSite's comment). The funnel does
// not switch off for prep.
const calmWind = windForSite(await loadStorm('storm_01_gentle'), siteDef, anchors);
current = calmWind; // the tree-sway closures fly the same calm day
const rows = [];
const n = anchors.length;
for (let a=0;a<n;a++) for (let b=a+1;b<n;b++) for (let c=b+1;c<n;c++) for (let d=c+1;d<n;d++) {
const ids = [anchors[a].id, anchors[b].id, anchors[c].id, anchors[d].id];
const s = new RiggingSession({ anchors, budget: 9999 });
let ok = true;
for (const id of ids) if (!s.rig(id).ok) { ok = false; break; }
if (!ok) continue;
for (const id of ids) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} RATED refused: ${r.reason} — the bench is flying hardware it did not buy`); }
s.setTension(1.0);
let rig;
try { rig = s.commit(new SailRig({ anchors, gridN: 10 })); } catch { continue; }
// settle exactly like prep: calm day, running clock, 12 s
let prepT = 0, peak = 0;
for (let i=0;i<Math.round(12/FIXED_DT);i++){
rig.step(FIXED_DT, calmWind, prepT % 3); prepT += FIXED_DT;
for (const co of rig.corners) if ((co.load||0) > peak) peak = co.load;
}
const broke = rig.corners.filter((co)=>co.broken).length;
const cov = rig.coverageOver(bed, {x:0,y:1,z:0}, null); // vertical: what hail sees
const sh = new RainShadow({ groundY: 0 }); sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
rows.push({ ids, broke, cov, frac, peak: peak/1000 });
}
const survived = rows.filter((r) => r.broke === 0);
log(`\n ${rows.length} quads swept on RATED steel. ${survived.length} survive PREP on the calm day.`);
log(`\n --- best COVER among quads that actually HOLD (this is the real ceiling) ---`);
log(` line vert.cover shadow peak kN broke`);
for (const r of survived.sort((p,q)=>q.cov-p.cov).slice(0,10)) {
log(` ${r.ids.join('+').padEnd(22)} ${(r.cov*100).toFixed(0).padStart(7)}% ${r.frac.toFixed(3).padStart(6)} ${r.peak.toFixed(2).padStart(7)} ${r.broke}`);
}
log(`\n --- best COVER overall, holding or not (what the sprint assumed was available) ---`);
log(` line vert.cover shadow peak kN broke`);
for (const r of rows.slice().sort((p,q)=>q.cov-p.cov).slice(0,6)) {
log(` ${r.ids.join('+').padEnd(22)} ${(r.cov*100).toFixed(0).padStart(7)}% ${r.frac.toFixed(3).padStart(6)} ${r.peak.toFixed(2).padStart(7)} ${r.broke}${r.broke?' <-- tears in PREP':''}`);
}
const canonical = siteId === 'backyard_01' ? 'p1+p2+p3+p4' : 'q1+q2+q3+tr1';
const cn = rows.find((r)=>r.ids.join('+')===canonical);
if (cn) log(`\n the canonical line ${canonical}: cover ${(cn.cov*100).toFixed(0)}% shadow ${cn.frac.toFixed(3)} peak ${cn.peak.toFixed(2)} kN broke ${cn.broke}`);
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'sweep2 done';
</script>
</body></html>

View File

@ -1,6 +1,7 @@
<!doctype html>
<!--
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool]
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool;
SPRINT13 gate 1.2 — the audit predicts the GARDEN now]
The node front-end (audit.mjs) is fast but blind: it cannot dress, so it only
sees posts + a hand-kept snapshot, and it REFUSES dress-source site JSON rather
@ -11,8 +12,17 @@
it. Then it runs the SAME sweep.js the node tool runs. This is the only way to
audit a site with a carport (site_02) before it ships.
SPRINT13: winnability was never the whole question — the audit's static
"cover%" predicted the wild night's garden BACKWARDS (r = 0.42) and told
three people a $20 rig was fine. Every affordable line is now FLOWN through
the real scoring chain (gardenfly.js: attach → settle → skyfx exposure →
garden.js) with the SITE's venturi live, and the verdict reports the garden
state the sim would invoice — FULL / tattered / dead — plus the site's
pinned `separation` target (A's gate-1.4 block in the site JSON), judged on
the block's own storm. cover% stays as a geometry diagnostic only.
tools/site_audit/audit.html?site=backyard_01
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
Served from the repo root (server.py), so the relative imports resolve.
-->
@ -45,6 +55,7 @@ import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
import { AUDIT, auditSweep } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'backyard_01';
@ -54,29 +65,39 @@ const el = (id) => document.getElementById(id);
const loadJSON = async (path) => (await fetch(path)).json();
async function run() {
// Build the site the way the game does — data in, dressed yard out.
// Build the site the way the game does — data in, dressed yard out — on a
// RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly
// world.anchors THEMSELVES. The old version here remapped every anchor to a
// static `sway: () => pos`, which froze the gum tree — the same landmine
// that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig
// eats the tree's sway as dynamic load). Backyard posts never noticed;
// every tr1/t1/t2 line was optimistic.
const site = await loadSite(siteName);
const scene = new THREE.Scene();
const calmStub = {
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
gustTelegraph: () => null, eventsBetween: () => [],
};
const world = createWorld(scene, { wind: calmStub, site });
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
let dressed = false;
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
// world.anchors are the REAL dressed positions — freeze sway like balance.test.
// ratingHint rides along (SPRINT12): the sweep prices hardware against
// rating × hint, and dropping it here would audit a yard where the fascia
// and the carport beam are honest steel — the exact lie the wiring killed.
const anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
return { id: a.id, type: a.type, ratingHint: a.ratingHint, pos, sway: () => pos };
});
// world.anchors, LIVE — sway closures and ratingHint intact. The sweep and
// every garden flight below re-point the proxy at their own wind via `use`.
const anchors = world.anchors;
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`);
const vlist = site.wind?.venturi ?? [];
const vtxt = vlist.length
@ -89,23 +110,86 @@ async function run() {
`venturi: ${vtxt}\n` +
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
// The venturi is the SITE's, not the storm's — main.js:424 sets it on the wind
// at every site load, so the audit must too or the corner block flies with its
// funnel switched off (an easier yard than ships).
const venturi = site.wind?.venturi ?? [];
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi });
// The venturi rides in on the SITE def — windForSite (C's shared builder,
// inside sweep.js and gardenfly.js) is the ONE door site wind comes through
// now. Three harnesses independently mis-built it; there is no fourth copy.
const bed = world.gardenBed;
const { cands, rows, verdict, winners, marginalWinners } =
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
// render rows
// ── SPRINT13: fly the garden for every line the budget can actually buy ──
// The scoring quantity is the FLOWN outcome (gardenfly.js — real attach,
// real skyfx exposure, real garden.js), not static cover%. Marginal lines
// fly too, deliberately: they are the trap the margin rule exists to name.
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
const FLY_CAP = 12;
const flown = new Map();
for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) {
// clean winners fly the CLEAN tiers (what the audit recommends buying);
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId
flown.set(r.ids.join(','), flyGarden({
anchors, bed, stormDef, siteDef: site, use,
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
}));
}
const skipped = Math.max(0, winners.length + marginalWinners.length - FLY_CAP);
// best GARDEN line the budget buys — the audit's answer to "what should I
// rig", which used to be "the cheapest line that holds" and is now "the line
// that saves the garden" — cheapest on ties, and never a marginal one (a
// marginal flight is C's 91.9-FULL illusion; it gets reported, not sold).
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
if (!best) return { key, g };
if (g.hp > best.g.hp + 0.05) return { key, g };
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
return best;
}, null);
const cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean);
const bestGarden = pickBest(cleanFlown);
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
// ── the site's pinned separation target (A's gate-1.4 ruling), judged on the
// block's OWN storm — the target is site data, not a per-run choice.
let sep = null, sepStormName = null;
if (site.separation) {
sepStormName = site.separation.stormKey;
const sepStorm = sepStormName === stormName ? stormDef
: await loadJSON(`../../web/world/data/storms/${sepStormName}.json`);
sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
}
// render rows — cover% is a DIAGNOSTIC column now (static overhead projection
// of the taut attach shape); the flown garden column is what scores.
const tbl = document.createElement('table');
const hd = tbl.insertRow();
for (const h of ['line', 'm²', 'vert cover (static)', 'holds?', 'garden (flown)', 'corners: peak → tier']) {
const td = hd.insertCell(); td.textContent = h; td.className = 'corner';
}
for (const r of rows) {
const tr = tbl.insertRow();
tr.insertCell().textContent = r.ids.join(',');
tr.insertCell().textContent = `${r.pinned ? '📌 ' : ''}${r.ids.join(',')}`;
tr.insertCell().textContent = `${r.area.toFixed(0)} m²`;
tr.insertCell().textContent = `cover ${(r.cover * 100).toFixed(0)}%`;
tr.insertCell().textContent = `${(r.cover * 100).toFixed(0)}%`;
const mk = tr.insertCell();
if (r.unholdable.length) { mk.textContent = '✗ unholdable'; mk.className = 'bad'; }
else if (r.hw <= START_BUDGET) { mk.textContent = `✓ $${r.hw}`; mk.className = 'ok'; }
else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
else if (!r.affordable) { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
else if (!r.clean) {
mk.textContent = `⚠ $${r.hw} MARGINAL (${r.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}%`).join(' ')})` +
` — clean ${r.cleanHw != null ? `$${r.cleanHw} > $${START_BUDGET}` : 'over the shop ceiling'}`;
mk.className = 'warn';
} else {
mk.textContent = `✓ $${r.cleanHw} clean${r.hw < r.cleanHw ? ` (holds at $${r.hw})` : ''}`;
mk.className = 'ok';
}
const gd = tr.insertCell();
const g = flown.get(r.ids.join(','));
if (g) {
gd.textContent = `${g.hp} ${g.state.toUpperCase()}${g.cornersLost ? ` (${g.cornersLost} lost)` : ''}` +
`${g.marginal.length ? ` ⚠ ${g.marginal.join(',')} inside margin` : ''}`;
gd.className = (g.marginal.length || g.state === 'tattered') ? 'warn' : g.state === 'full' ? 'ok' : 'bad';
} else { gd.textContent = '—'; gd.className = 'corner'; }
const cs = tr.insertCell(); cs.className = 'corner';
cs.innerHTML = r.tiers.map((c) =>
`${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
@ -114,26 +198,79 @@ async function run() {
// verdict
const v = el('verdict');
const gardenLine = () => {
const bg = bestGarden ? rowBy(bestGarden.key) : null;
return `\n— the garden (flown, funnel ${vlist.length ? 'ON' : 'n/a'}): bare bed ${bare.hp} ${bare.state.toUpperCase()}` +
(bestGarden
? ` · best clean buyable ${bg.ids.join(',')} ($${bg.cleanHw}) → ${bestGarden.g.hp} ${bestGarden.g.state.toUpperCase()}` +
`${bestGarden.g.cornersLost ? ` (${bestGarden.g.cornersLost} corner(s) lost)` : ''}` +
` · the sail is worth ${(bestGarden.g.hp - bare.hp) >= 0 ? '+' : ''}${(bestGarden.g.hp - bare.hp).toFixed(1)} HP`
: ' · NO clean buyable line to fly') +
(bestMarginal && (!bestGarden || bestMarginal.g.hp > bestGarden.g.hp)
? `\n ⚠ best MARGINAL line ${bestMarginal.key} reads ${bestMarginal.g.hp} ${bestMarginal.g.state.toUpperCase()} on paper — ` +
`inside the 15% break-in-game band, treat as ${bestMarginal.g.hp > bare.hp ? 'the trap, not the answer' : 'dead'}`
: '') +
(skipped ? `\n (${skipped} affordable line(s) beyond the ${FLY_CAP}-flight cap not flown)` : '');
};
const sepLine = () => {
if (!sep) return `\n— separation target: none pinned in the site JSON (A's gate-1.4 block) — nothing to judge against.`;
const s = site.separation;
return `\n— separation target (${sepStormName}): ` +
`held ${sep.held.hp} ${sep.held.state.toUpperCase()} ($${sep.held.cost}, ${sep.held.cornersLost}/4 lost` +
`) vs pinned >${s.heldMustExceed}` +
` · bare ${sep.bare.hp} vs pinned <${s.bareMustLoseBelow}` +
` → ${sep.ok ? 'MEETS the target' : `FAILS (${[!sep.heldOk && 'held not FULL', !sep.heldWin && 'held does not WIN', !sep.bareOk && 'bare does not lose'].filter(Boolean).join(', ')})`}` +
(sep.ok && !sep.heldClean
? `\n ⚠ but the pinned line is KNIFE-EDGED: ${sep.held.marginal.map((id) => {
const p = sep.held.peaks.find((x) => x.id === id);
return `${id} ${(p.headroom * 100).toFixed(0)}% headroom (${p.peakN}/${p.effN} N)`;
}).join(', ')} — inside the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band. ` +
`Flagged to A/D in THREADS, not overruled here: the margin rule's cause is unfound and this yard's ` +
`bench has matched the UI to the decimal — but nobody has PLAYED this line yet.`
: '');
};
if (verdict.code === 'no-cover') {
v.className = 'verdict fail';
v.textContent = `✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.\n` +
`The site cannot be rigged. It needs an anchor near the bed before it ships.`;
} else if (verdict.code === 'marginal-only') {
const b = verdict.best;
v.className = 'verdict fail';
v.textContent = `✗ MARGINAL ONLY — the budget's ${marginalWinners.length} affordable line(s) all carry a corner inside ` +
`the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (best: ${b.ids.join(',')} at $${b.hw}, ` +
`${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')}; ` +
`clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).\n` +
`On the bench it holds; in the real game it is D's 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.` +
gardenLine() + sepLine();
} else if (!verdict.ok) {
const b = verdict.best;
v.className = 'verdict fail';
v.textContent = `✗ FAIL — no affordable holding line. Cheapest is ${b.ids.join(',')} at $${b.hw} on a $${START_BUDGET} budget` +
(b.unholdable.length ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.') +
`\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.`;
`\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.` +
gardenLine() + sepLine();
} else {
const w = verdict.best;
v.className = 'verdict pass';
v.textContent = `✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`;
const wTotal = w.cleanHw + AUDIT.SPARE_COST;
v.className = (sep && !sep.ok) ? 'verdict fail' : 'verdict pass';
v.textContent = `${(sep && !sep.ok) ? '✗' : '✓'} winnability: ${winners.length} clean affordable line(s)` +
`${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}; cheapest clean ${w.ids.join(',')} — $${w.cleanHw} hardware` +
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}.` +
gardenLine() + sepLine();
}
// a machine-readable line, so this page can also be driven headless-in-browser
window.__audit = { site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover })) };
document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`;
window.__audit = {
site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length,
cands: cands.length, verdict,
winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover, garden: flown.get(w.ids.join(',')) ?? null })),
marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw,
marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })),
garden: flown.get(w.ids.join(',')) ?? null })),
bare, bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
separation: sep ? { storm: sepStormName, ...sep } : null,
};
document.title = `site_audit — ${verdict.ok ? 'PASS' : verdict.code.toUpperCase()}${sep ? ` · sep ${sep.ok ? 'MEETS' : 'FAILS'}` : ''}`;
}
run().catch((e) => {

View File

@ -16,11 +16,19 @@
* balance decision disguised as art, and it should be audited the minute it's
* drawn, not the sprint after it ships.
*
* What it does NOT do: score the garden. Winnability is decided before any of
* that by whether a quad exists that (a) covers the bed and (b) has peak
* corner loads the budget can hold. The garden drain only decides how BADLY you
* lose a site that was already unwinnable. That's also why this runs in node:
* no skyfx, no document, no browser, ~20 s per site.
* What it does NOT do: score the garden and SPRINT13 made the garden the
* scoring quantity (the audit's old static cover% predicted the wild night's
* garden BACKWARDS, r = 0.42). skyfx needs `document`, so garden truth lives
* in the BROWSER front-end: audit.html flies every affordable line through
* gardenfly.js (real attach skyfx exposure garden.js) and judges the
* site's pinned `separation` block. This node tool remains the fast
* winnability check peaks, pricing, the margin rule and it TELLS you
* where the garden verdict is rather than pretending it has one.
*
* Known blindness beyond dressing (stated, not hidden): node hands the sweep
* STATIC anchors, so tree sway real dynamic load on any tr1/t1/t2 line,
* C's landmine 2 is frozen here. Post lines are exact; tree lines read
* optimistic. The browser front-end flies world.anchors live.
*/
import { readFile } from 'node:fs/promises';
@ -94,11 +102,15 @@ const BACKYARD_01 = {
['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0],
['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0],
['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0],
// SPRINT13: p5, the clothesline post — A's gate-1 anchor (the ruling that
// gave the backyard a FULL-capable wild-night line). h 2.6 in the JSON,
// raked like every post; verified live like every post.
['p5', 'post', 5.840064309022781, 2.591333620780842, -2.1236597487355566, 1.0],
].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })),
};
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4', 'p5']);
/**
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
@ -187,7 +199,9 @@ async function loadSite(path) {
}));
// A resolved export still owes us the site's funnel — the venturi is site data,
// not storm data, and a sweep without it flies an easier yard than ships.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] };
// The separation block rides along so the tool can at least PRINT the pin.
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors,
venturi: j.wind?.venturi ?? [], separation: j.separation ?? null };
}
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
@ -196,7 +210,6 @@ async function main() {
const site = await loadSite(sitePath);
const anchors = withSway(site.anchors);
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8'));
console.log(`\nsite_audit — ${site.name}`);
if (site.dumped) {
@ -211,6 +224,9 @@ async function main() {
console.log(` posts verified against live world.js ✓ ` +
`dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
// the pinned separation block is site JSON — read it so the dump path can print the pin
const j = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
site.separation = j.separation ?? null;
}
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
const venturi = site.venturi ?? [];
@ -218,7 +234,11 @@ async function main() {
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
// The sweep itself is shared with the browser front-end — see sweep.js.
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef, venturi });
// siteDef carries the venturi AND the pinned separation recipe (the pin
// sweeps regardless of the band — see sweep.js).
const { cands, rows, winners, marginalWinners, verdict } =
auditSweep({ anchors, bed: site.bed, stormDef: def,
siteDef: { wind: { venturi }, separation: site.separation ?? null } });
if (verdict.code === 'no-cover') {
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
@ -226,14 +246,33 @@ async function main() {
process.exit(1);
}
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
console.log(`${cands.length} quad(s) in band shading the bed (cover% is the STATIC vertical projection — a`);
console.log(`geometry diagnostic; the garden verdict is flown in audit.html via gardenfly.js):\n`);
for (const r of rows) {
const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `$${r.hw}` : `$${r.hw} > $${START_BUDGET}`;
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
const mark = r.unholdable.length ? '✗ unholdable'
: !r.affordable ? `$${r.hw} > $${START_BUDGET}`
: !r.clean ? `$${r.hw} MARGINAL(${r.marginal.map((c) => c.id).join(',')})`
: `$${r.cleanHw} clean`;
console.log(` ${((r.pinned ? '📌' : '') + r.ids.join(',')).padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(22)} ${cs}`);
}
if (site.separation) {
console.log(`\nseparation target pinned (${site.separation.stormKey}): ` +
site.separation.line.map((l) => `${l.anchor} ${l.hw}`).join(' + ') +
` — held >${site.separation.heldMustExceed} & WIN, bare <${site.separation.bareMustLoseBelow}.` +
`\n Judged in the BROWSER front-end (garden needs skyfx): tools/site_audit/audit.html?site=...`);
}
console.log('');
if (verdict.code === 'marginal-only') {
const b = verdict.best;
console.log(`✗ MARGINAL ONLY — every affordable line carries a corner inside the ` +
`${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (C's residual rule). Best: ${b.ids.join(',')} at $${b.hw}` +
` (${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')};` +
` clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).`);
console.log(` On this bench it holds; in the real game it is the 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.\n`);
process.exit(1);
}
if (!verdict.ok) {
const best = verdict.best;
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
@ -242,9 +281,12 @@ async function main() {
process.exit(1);
}
const w = verdict.best;
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')}$${w.hw} hardware` +
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
const wTotal = w.cleanHw + AUDIT.SPARE_COST;
console.log(`✓ PASS — ${winners.length} clean affordable line(s)` +
`${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}. ` +
`Best clean: ${w.ids.join(',')}$${w.cleanHw} hardware` +
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% static cover. Garden verdict: audit.html.\n`);
}
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });

View File

@ -0,0 +1,164 @@
<!doctype html>
<!--
garden_probe — does cover% predict the garden? [Lane B, SPRINT13 gate 1.2]
The QA pass found one variable sighted three times: the audit's "cover" called
a $20 rig bad and the sim paid it +$97. This page is the measurement behind
that — it flies the SIM's own garden loop (skyfx's gardenExposure /
gardenHailExposure, stepped exactly the way main.js:931-941 steps it) for a
set of rig lines, and prints predicted garden HP beside the audit's cover%.
SPRINT13: the garden model is A's js/garden.js now (the export this page's
first version asked for), so the probe flies the SAME createGarden main.js
flies — the temporary constant copy died the day the export landed, per the
fake → ask → landed → delete-the-fake protocol.
Browser-only by necessity: skyfx needs `document` (verified — node throws
"document is not defined"), so garden prediction can never live in audit.mjs.
tools/site_audit/garden_probe.html?site=site_02_corner_block&storm=storm_03b_earlybuster
-->
<meta charset="utf-8">
<title>garden_probe</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:8px 0; }
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
th { color:#9ab; border-bottom:1px solid #333; }
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
</style>
<h1>garden_probe — does cover% predict the garden?</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<div class="note" id="note"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { HARDWARE } from '../../web/world/js/contracts.js';
import { auditSweep } from './sweep.js';
import { flyGarden } from './gardenfly.js';
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'site_02_corner_block';
const stormName = q.get('storm') || 'storm_03b_earlybuster';
const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json();
async function run() {
// The yard on a re-pointable wind proxy (C's bench pattern) so world.anchors
// fly THEMSELVES — a static sway remap froze the gum tree and under-read
// every tree corner (C's landmine 2; q4 1.02 frozen vs 1.24 live).
const site = await loadSite(siteName);
const scene = new THREE.Scene();
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
await world.dress();
const bed = world.gardenBed;
const anchors = world.anchors;
const venturi = site.wind?.venturi ?? [];
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
// The audit's own sweep, for its (diagnostic) cover% numbers.
const { rows } = auditSweep({ anchors, bed, stormDef, siteDef: site, use });
/** Fly one line through gardenfly — THE audit scoring chain, no local copy. */
const gardenFor = (ids, hwTier, { bare = false } = {}) =>
flyGarden({ anchors, bed, stormDef, siteDef: site, use,
ids: bare ? null : ids, hw: hwTier });
el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` +
`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain}`).join(' · ') : 'none'} · ` +
`flying gardenfly.js — the sim's own chain (windForSite → attach → skyfx → garden.js), live anchors`;
// The bare bed is the control: what the garden does with NO sail at all.
const bareRes = gardenFor(null, null, { bare: true });
// Probe a spread of lines: the cheapest (the decoy), the best-covering, and a
// few between — sorted by the audit's cover% so any correlation is visible.
const holdable = rows.filter((r) => !r.unholdable.length);
const byCover = [...holdable].sort((a, b) => a.cover - b.cover);
const pick = [];
const want = 8;
for (let i = 0; i < want; i++) pick.push(byCover[Math.floor(i * (byCover.length - 1) / (want - 1))]);
const seen = new Set();
const probes = pick.filter((r) => r && !seen.has(r.ids.join(',')) && seen.add(r.ids.join(',')));
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const h of ['line', 'audit cover% (static)', 'hw $', 'garden (flown)', 'by hail', 'by rain', 'corners lost', 'vs bare bed'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const results = [];
for (const r of probes) {
const g = gardenFor(r.ids, HARDWARE[2]); // rated shackles: hold it, isolate GEOMETRY
results.push({ ids: r.ids.join(','), cover: +(r.cover * 100).toFixed(0), hw: r.hw, ...g });
const tr = tbl.insertRow();
const cells = [r.ids.join(','), `${(r.cover * 100).toFixed(0)}%`, `$${r.hw}`,
`${g.hp.toFixed(1)} ${g.state.toUpperCase()}${g.marginal.length ? ` ⚠ ${g.marginal.join(',')}` : ''}`,
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
`${(g.hp - bareRes.hp >= 0 ? '+' : '')}${(g.hp - bareRes.hp).toFixed(1)}`];
cells.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c;
if (i === 7) td.className = (g.hp - bareRes.hp) > 15 ? 'ok' : (g.hp - bareRes.hp) > 5 ? 'warn' : 'bad'; });
}
const tr = tbl.insertRow();
['BARE BED (no sail)', '—', '$0', `${bareRes.hp.toFixed(1)} ${bareRes.state.toUpperCase()}`,
bareRes.byHail.toFixed(1), bareRes.byRain.toFixed(1), '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl);
// Correlation between the audit's cover% and the sim's garden HP. If the audit
// is telling the truth this is near 1; the QA pass says it is not.
const xs = results.map((r) => r.cover), ys = results.map((r) => r.hp);
const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length;
const mx = mean(xs), my = mean(ys);
const cov = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0);
const sx = Math.sqrt(xs.reduce((s, x) => s + (x - mx) ** 2, 0));
const sy = Math.sqrt(ys.reduce((s, y) => s + (y - my) ** 2, 0));
const r2 = (sx && sy) ? (cov / (sx * sy)) : NaN;
const spread = Math.max(...ys) - Math.min(...ys);
const bestGain = Math.max(...ys) - bareRes.hp;
el('note').textContent =
`cover% → garden HP correlation: r = ${r2.toFixed(3)}\n` +
`garden HP spread across every holdable line: ${spread.toFixed(1)} HP\n` +
`best line vs bare bed: ${bestGain >= 0 ? '+' : ''}${bestGain.toFixed(1)} HP ` +
`(a rig that costs money and holds every corner buys THIS much garden)\n` +
`bare bed finished at ${bareRes.hp.toFixed(1)} HP — hail ${bareRes.byHail.toFixed(1)}, rain ${bareRes.byRain.toFixed(1)}`;
window.__probe = { site: siteName, storm: stormName, bare: bareRes, results, r: r2, spread, bestGain };
document.title = `garden_probe — r=${r2.toFixed(2)}`;
}
run().catch((e) => {
el('note').className = 'bad';
el('note').textContent = `garden_probe crashed: ${e.message}\n${e.stack || ''}`;
window.__probe = { error: e.message };
document.title = 'garden_probe — ERROR';
});
</script>

View File

@ -0,0 +1,175 @@
/**
* gardenfly.js fly a rig line through the REAL scoring chain and return the
* garden verdict the sim would hand the invoice. [Lane B, SPRINT13 gate 1.2]
*
* This module exists because the audit's old ranking quantity static cover%
* of the taut attach-shape predicted the wild night's garden BACKWARDS
* (r = 0.42, THREADS [B] gate-1 evidence): it told three people a $20 rig was
* bad while the sim paid it +$97. The quantity that scores is the FLOWN
* vertical (hail) shadow through the storm: hail is 5.0 against rain's 0.25
* (garden.js), stones fall near-vertical, and a sail either blocks the hail
* over the bed or blocks nothing that matters. Only flying the real chain
* measures that, so this is the audit's scoring engine now; cover% is demoted
* to a geometry diagnostic in both front-ends.
*
* THE chain, one copy of it, shared by audit.html and garden_probe.html:
*
* windForSite(stormDef, siteDef, anchors) (C's shared builder
* venturi + tree shelters, byte-for-byte main.js's site-load wiring)
* SailRig.attach(ids, hw, tension) (the real attach)
* rig.step + sky.step(dt, t, {sail: rig}) (main.js's exact call)
* createGarden(...).step(dt, hailExp, rainExp) (A's garden.js, no copy)
*
* NO calm settle, deliberately: in the real game the rig does not exist before
* commit, and commitattachstorm is one keypress (the rig.t fix's own words),
* so the attach transient flies under STORM wind and counts. C measured the
* phantom settle's skew and removed it from the bench the same day.
*
* Landmines this file exists to never re-arm (each earned this sprint):
*
* · D's skipped-attach trap (skyfx.js:803): a harness that never runs
* attach() scores EVERY rig as bare the bare-bed constant lies politely.
* flyGarden throws if the rig it built has no shadow mesh.
* · The venturi lives in the SITE def, not the storm def. THREE harnesses
* shipped that bug (my Sprint-11 audit, C's bench, D's first harness half);
* windForSite is the one door now, and the selftest mutation-checks that
* the funnel reaches this module's wind.
* · The FROZEN TREE (C's landmine 2): remapping anchors to `sway: () => pos`
* freezes the gum tree whose sway is the dynamic load a tr1 rig eats
* q4 read 1.02 frozen vs 1.24 live. Callers hand this module
* world.anchors THEMSELVES and re-point the world's wind proxy via `use`
* so the sway closures sample the storm that is actually flying.
* · THE MARGIN RULE (C's residual): even corrected, benches under-read the
* real UI's peaks by ~515% on site_02 (cause unfound, in the pool). Until
* it is found, any corner within 15% of its effective rating is scored
* "breaks in the game" a flight that holds on paper with q4 at 1.24 vs
* 1.2 is D's 39.8 TATTERED in play, not the bench's 91.9 FULL. flyGarden
* reports `marginal` and callers must not print PASS over it.
*
* Per-corner peaks are labelled from `corners[k].anchorId`, never input order
* C's label-permutation warning (attach reorders picks into ring order).
*
* Browser-only by necessity: skyfx needs `document` (node throws), which is
* why audit.mjs (node) prints winnability and POINTS HERE for garden truth.
*/
import { SailRig } from '../../web/world/js/sail.js';
import { windForSite } from '../../web/world/js/weather.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
import { AUDIT } from './sweep.js';
// The margin rule's knob is AUDIT.MARGIN — sweep.js holds the ONE copy and
// both audit engines read it (a local copy here would be the exact drift this
// sprint spent itself killing).
/** Shop hardware by its contracts.js name ("rated shackle") throws on a
* stranger, because a typo'd tier silently becoming a carabiner is exactly
* the setHardware() lesson from gate 3. */
export function hardwareByName(name) {
const hw = HARDWARE.find((h) => h.name === name);
if (!hw) throw new Error(`unknown hardware "${name}" — the shop sells: ${HARDWARE.map((h) => h.name).join(', ')}`);
return hw;
}
/**
* Fly one line (or a bare bed) and return the garden verdict.
*
* @param {object} o
* @param {Array} o.anchors world.anchors THEMSELVES (live sway), or resolved
* {id, type, pos, sway, ratingHint} for synthetic yards
* @param {object} o.bed garden bed rect {x, z, w, d}
* @param {object} o.stormDef storm JSON to fly
* @param {object} [o.siteDef] site JSON its wind.venturi is half the yard's
* weather; omit ONLY for a site with no funnel
* @param {function} [o.use] yard's wind-proxy re-pointer (C's bench pattern):
* called with this flight's wind so world.anchors'
* tree-sway closures sample the storm actually flying
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
* @param {number} [o.tension]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
*/
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
let rig = null, cost = 0;
if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (porosity 0.30): the fabric a competent player flies — same as sweep.js
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension);
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
// bed and the number LOOKS plausible. Refuse to fly it.
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {
throw new Error('flyGarden: rig is not attached (no shadow mesh) — a bypassed attach scores ' +
'every rig as bare (skyfx.js:803, D\'s landmine). Fix the harness, do not trust the number.');
}
}
const sky = createSkyFx({ wind, night: true });
const garden = createGarden({ setPlants() {} }); // THE model — garden.js, main.js's own
const n = Math.round(stormDef.duration / FIXED_DT);
for (let i = 0; i < n; i++) {
const t = i * FIXED_DT;
if (rig) rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig }); // main.js's exact third param
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
}
sky.dispose?.();
// labelled from the rig's OWN anchorId — attach reorders picks into ring
// order, and a peak quoted against input order swaps corners (C's warning).
const peaks = rig ? rig.corners.map((c) => {
const eff = c.hw.rating * (c.anchor.ratingHint ?? 1);
return { id: c.anchorId, peakN: Math.round(c.peakLoad), effN: Math.round(eff),
headroom: +(1 - c.peakLoad / eff).toFixed(3) };
}) : [];
return {
hp: +garden.hp.toFixed(1),
state: garden.state, // 'full' | 'tattered' | 'dead' — garden.js's own thresholds
byHail: +garden.damage.hail.toFixed(1),
byRain: +garden.damage.rain.toFixed(1),
cornersLost: rig ? rig.corners.filter((c) => c.broken).length : 4,
peaks,
// C's margin rule: corners that held on paper but sit within MARGIN of
// their effective rating — in the real game these break. A prediction
// carrying names here is NOT a clean hold, whatever the hp says.
marginal: peaks.filter((p) => !rig.corners.find((c) => c.anchorId === p.id).broken && p.headroom < AUDIT.MARGIN)
.map((p) => p.id),
cost,
};
}
/**
* Judge a site against its pinned `separation` block (A's gate-1.4 ruling,
* backyard_01.json): fly the pinned recipe AND a bare bed on the block's own
* storm, and answer the only question the target asks does the best line the
* budget buys read FULL and WIN, and does a bare bed LOSE the night.
*
* `heldWin` is main.js's win rule (hp >= 50 && corners lost < 2) EXACTLY the
* rule, so this stays in lockstep with a.test's pin of the same block.
* `heldClean` is the margin rule's opinion, kept SEPARATE from `ok` on
* purpose: the pinned target is A's ruling and this module doesn't get to
* overrule it with a working rule whose cause is still unfound but a pinned
* line carrying a corner inside the 15% band is a knife edge the front-end
* must SAY (it's a flag for A/D, not a verdict).
*
* @returns {{ held, bare, heldOk, heldWin, heldClean, bareOk, ok }}
*/
export function flySeparation({ anchors, bed, separation, stormDef, siteDef = null, use = null }) {
const ids = separation.line.map((l) => l.anchor);
const hw = separation.line.map((l) => hardwareByName(l.hw));
const tension = separation.tension ?? 1.0;
const held = flyGarden({ anchors, bed, stormDef, siteDef, use, ids, hw, tension });
const bare = flyGarden({ anchors, bed, stormDef, siteDef, use });
const heldOk = held.hp > separation.heldMustExceed;
const heldWin = held.hp >= 50 && held.cornersLost < 2;
const heldClean = held.marginal.length === 0;
const bareOk = bare.hp < separation.bareMustLoseBelow;
return { held, bare, heldOk, heldWin, heldClean, bareOk, ok: heldOk && heldWin && bareOk };
}

View File

@ -0,0 +1,178 @@
/**
* gardenfly.selftest.js the audit's garden engine audits itself. [Lane B, SPRINT13]
*
* Browser-only (skyfx needs `document`), so unlike sweep.selftest.js this is an
* ASYNC factory: b.test.js awaits it and registers the returned [name, fn]
* pairs. The flights happen here, once; the tests assert on captured results
* the same Suite-cannot-await shape a.test.js's pinned-separation flight uses.
*
* What it pins, and why each can fail:
*
* 1. TWO HARNESSES, ONE PIN. a.test flies backyard_01's `separation` block
* through the RiggingSession shop path; this flies the SAME block through
* the audit's own chain (gardenfly: windForSite, direct attach, named
* hardware). Both must land on A's ruling held FULL and winning, bare
* losing. If a retune moves the wild night, both go red together and the
* argument happens in THREADS, not in a drifted tool.
*
* 2. THE VENTURI REACHES THE FLOWN WIND. Three harnesses shipped the same
* bug (site_audit SPRINT11, C's bench, D's first harness): the funnel
* lives in the SITE def and a harness that doesn't wire it flies an
* easier yard than ships. Same synthetic-yard trick as sweep.selftest:
* starve gardenfly of the siteDef's venturi and this goes red.
*
* 3. The shop has no mystery tier: hardwareByName throws on a stranger
* rather than quietly handing back nothing (the setHardware lesson).
*/
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
import { SailRig } from '../../web/world/js/sail.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { FIXED_DT } from '../../web/world/js/contracts.js';
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
/** sweep.selftest's synthetic yard: one quad, storm dead along +Z, funnel on it. */
const SYN_ANCHORS = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const SYN_BED = { x: 0, z: 0, w: 4, d: 4 };
const SYN_STORM = { id: 'gardenfly_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 } };
const SYN_FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
export async function buildGardenflyTests() {
// The real yard, the way the game builds it — on a re-pointable wind proxy
// (C's bench pattern) so world.anchors fly THEMSELVES with live sway (a
// static remap froze the gum tree, C's landmine 2). Fail LOUD if dress
// fails — an undressed yard has no fascia hint, and a garden number
// measured on it is about a different game (C's bare-specifier gun).
const site = await loadSite('backyard_01');
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(new THREE.Scene(), { wind: windProxy, site });
await world.dress();
let sepRun = null, skewRun = null, sepErr = null;
if (site.separation) {
try {
const stormDef = await loadStorm(site.separation.stormKey);
sepRun = flySeparation({
anchors: world.anchors, bed: world.gardenBed,
separation: site.separation, stormDef, siteDef: site, use,
});
// The OTHER harness's chain, reproduced on purpose: a.test settles 12 s
// on the calm day before the storm, and SailRig samples wind at its
// INTERNAL clock — so its storm flies 12 s off the authored curve
// against the sky's t. Measured (2026-07-18, tmp probe → THREADS): the
// skew is worth +4.6 HP on the pinned line (63.8 game-true vs 68.4
// skewed; A's 68.3 to within 0.1, peaks to the digit). This flight
// exists so BOTH harnesses are pinned from one file: if either chain
// drifts, the agreement asserts below go red and name which one.
const calmDef = await loadStorm('storm_01_gentle');
const sep = site.separation;
const wind = windForSite(stormDef, site, world.anchors);
const calm = windForSite(calmDef, site, world.anchors);
use(calm);
const rig = new SailRig({ anchors: world.anchors, gridN: 10, porosity: 0.30 })
.attach(sep.line.map((l) => l.anchor), sep.line.map((l) => hardwareByName(l.hw)), sep.tension ?? 1);
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calm, (i * FIXED_DT) % 3);
use(wind);
const sky = createSkyFx({ wind, night: true });
const garden = createGarden({ setPlants() {} });
const bed = world.gardenBed;
for (let i = 0, n = Math.round(stormDef.duration / FIXED_DT); i < n; i++) {
const t = i * FIXED_DT;
rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
}
sky.dispose?.();
skewRun = { hp: +garden.hp.toFixed(1), lost: rig.corners.filter((c) => c.broken).length };
} catch (err) { sepErr = String((err && err.stack) || err); }
}
let venRun = null, venErr = null;
try {
const fly = (venturi) => flyGarden({
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
siteDef: { wind: { venturi } },
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
});
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
} catch (err) { venErr = String((err && err.stack) || err); }
return [
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
// the pinned recipe HOLDS and rigging matters by a full state's width —
// but the >66 FULL half of the target is only met in a.test's chain,
// whose 12 s calm settle runs the storm 12 s off the authored curve
// (SailRig samples wind at its INTERNAL clock). The game-true chain
// (commit→attach→storm at t=0, this module) reads ~63.8 TATTERED.
// Ruled numbers are A's; the game is D's to play; this test pins BOTH
// measurements so any drift in either chain goes red and names itself.
// When A re-rules (fix a.test's harness, restate the threshold, or
// re-steel the recipe), the constants below move WITH the ruling.
if (!site.separation) throw new Error('backyard_01 lost its separation block — the audit has no target to read');
if (sepErr) throw new Error(`separation flight died: ${sepErr}`);
const s = site.separation;
assert(sepRun.held.cost <= 80, `the pinned recipe costs $${sepRun.held.cost} — must be night-1 buyable`);
assert(sepRun.held.cornersLost === 0, `pinned line lost ${sepRun.held.cornersLost}/4 corners — it must HOLD the wild night`);
assert(sepRun.bareOk, `bare bed ${sepRun.bare.hp} vs pinned <${s.bareMustLoseBelow} — bare must LOSE the night`);
assert(sepRun.held.hp - sepRun.bare.hp > 25,
`separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by a full state's width`);
// RESOLVED at SPRINT13 integration, per this assert's own instruction: A
// re-ruled heldMustExceed 66 → 60 on the game-true number (their THREADS
// entry, "restated, not nudged"), so the two-chain disagreement this used
// to pin is closed and the pin is asserted directly. B pre-authorized the
// edit in the message above; A verified 357/0/0 on a scratch merge first.
assert(sepRun.heldOk,
`game-true chain reads ${sepRun.held.hp} vs pinned >${s.heldMustExceed} — the held bed must ` +
`clear the site's separation block for the RIGHT reason (no settle skew)`);
assert(skewRun.lost === 0 && skewRun.hp > s.heldMustExceed,
`a.test's settle-skewed chain reads ${skewRun.hp} — measured 68.4 at landing (a.test's own 68.3). ` +
`If this dropped, a.test's pin is about to go red too: the wild night itself moved`);
assert(sepRun.ok === (sepRun.heldOk && sepRun.heldWin && sepRun.bareOk),
'flySeparation.ok must agree with its own parts');
}],
['gardenfly: the SITE venturi reaches the flown wind (the bench landmine, third time charmed)', () => {
if (venErr) throw new Error(`venturi flight died: ${venErr}`);
const { bare, funnelled } = venRun;
assert(bare.peaks.length === 4 && funnelled.peaks.length === 4, 'both flights fly 4 corners');
for (const b of bare.peaks) {
// align by anchorId — peaks are ring-ordered, never input-ordered (C's warning)
const f = funnelled.peaks.find((p) => p.id === b.id);
assert(f.peakN > b.peakN,
`corner ${b.id}: funnelled peak ${f.peakN} N is not above bare ${b.peakN} N — ` +
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
}
}],
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
let threw = false;
try { hardwareByName('titanium dream'); } catch { threw = true; }
assert(threw, 'hardwareByName("titanium dream") must throw, not hand back undefined');
assert(hardwareByName('rated shackle').rating === 6500, 'and the real names still resolve');
}],
];
}

View File

@ -7,25 +7,44 @@
* differ in how they GET the anchors and how they PRINT the result. A tool built
* to catch reimplemented-formula drift must not carry two copies of its own math.
*
* Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}),
* the bed rect, the storm + calm-day defs, and the site's venturi list. It flies
* the same settle+storm the game flies and returns ranked rows + a verdict.
* No I/O, no process, no DOM.
* Pure given its inputs: hand it anchors (world.anchors themselves, ideally
* live sway matters, see below), the bed rect, the storm def and the SITE def,
* and it flies the same attachstorm chain the game flies and returns ranked
* rows + a verdict. No I/O, no process, no DOM.
*
* SPRINT13: winnability rows carry the margin rule now (AUDIT.MARGIN) a
* corner priced inside 15% of its effective rating holds on this bench and
* "breaks in the game" (C's residual, cause unfound), so every row prices
* twice: `hw` (holds) and `cleanHw` (holds with margin), and only clean lines
* are winners. The garden side of the audit lives in gardenfly.js (browser).
*/
import { SailRig, orderRing } from '../../web/world/js/sail.js';
import { createWind } from '../../web/world/js/weather.js';
import { windForSite } from '../../web/world/js/weather.js';
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
/** Audit knobs, in one place so both front-ends and any future site agree. */
export const AUDIT = {
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
MIN_COVER: 0.25, // A's "shades the bed" bar
SETTLE_S: 12, // D's settle — a player plays through prep
PRE_GUST_S: 3, // hold the settle inside gentle's pre-gust window (balance.test)
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
CALM_STORM: 'storm_01_gentle',
/**
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
* the real UI's peaks by ~515% on site_02 (cause unfound, in the pool).
* Until it's found, a corner priced within this fraction of its effective
* rating "breaks in the game" the bench held q4 at 1.24-vs-1.2 and read
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
* A line with a marginal corner is flagged, never sold as a clean PASS.
*/
MARGIN: 0.15,
};
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
// settle they parameterised. In the real game the rig does not exist before
// commit — commit→attach→storm is one keypress (the rig.t fix's own words) —
// so the attach transient flies under STORM wind and its loads count. The old
// 12 s calm settle + resetPeaks measured a chain the game never flies, and
// also skewed every storm sample 12 s off the authored curve (C measured both
// on the bench and removed its copy the same day).
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
export function areaOf(q) {
@ -52,20 +71,28 @@ export const tierFor = (peakN, ratingHint = 1) =>
/**
* Sweep every bed-covering quad and price the cheapest holding line.
* @param {object} o
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway,
* ratingHint } omit ratingHint and the anchor is
* priced at full hardware strength (hint 1), which
* is a LIE for any GLB-dressed anchor (fascia 0.35,
* @param {Array} o.anchors world.anchors THEMSELVES where possible (their
* sway closures carry the tree's real movement a
* static remap froze the gum tree and under-read
* every tr1 corner, C's landmine 2), or resolved
* { id, type, pos, sway, ratingHint } for synthetic
* yards. Omit ratingHint and the anchor is priced
* at full hardware strength (hint 1), which is a
* LIE for any GLB-dressed anchor (fascia 0.35,
* carport beam 0.22). Hand this the dressed truth.
* @param {object} o.bed garden bed rect { x, z, w, d }
* @param {object} o.stormDef the storm JSON to fly
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi).
* Omitted = no funnel, which is backyard_01's truth
* and a LIE on the corner block. See below.
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
* @param {object} [o.siteDef] the SITE json its wind.venturi is half the
* yard's weather (three harnesses learned this the
* hard way); preferred over `venturi`
* @param {Array} [o.venturi] bare funnel list, for synthetic yards with no
* site JSON (sweep.selftest). Ignored if siteDef given.
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
* pattern) called with the sweep's wind so live
* tree-sway closures sample the storm being flown
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
*/
export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
// 1. every quad, in the rigging band, that shades the bed
const cands = [];
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
@ -81,61 +108,93 @@ export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
}
if (!cands.length) return { cands, rows: [], winners: [], verdict: { ok: false, code: 'no-cover', best: null } };
// The site's PINNED separation line sweeps regardless of the band: A's
// gate-1.4 recipe (p1+p2+p3+p5) is 45.9 m² against the band's 45 — the band
// is an audit heuristic calibrated before p5 existed, and an audit that
// cannot see the site's own ruled-best line is auditing a different yard.
// Flagged `pinned` so front-ends can say why it's there; band-vs-pin
// reconciliation is A's (posted in THREADS).
const pinIds = siteDef?.separation?.line?.map((l) => l.anchor);
if (pinIds && !cands.some((c) => pinIds.every((id) => c.ids.includes(id)))) {
const q = pinIds.map((id) => anchors.find((a) => a.id === id)).filter(Boolean);
if (q.length === 4) {
try {
const rig = new SailRig({ anchors, gridN: 10 }).attach(pinIds, Array(4).fill(HARDWARE[2]), 1.0);
cands.push({ ids: pinIds, area: areaOf(q), cover: rig.coverageOver(bed, { x: 0, y: 1, z: 0 }), pinned: true });
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
}
}
// 2. peak corner loads, settled the way the game settles, on the real storm.
// Every clause here is a bug the first draft shipped:
// · createWind + setSheltersFromTrees — trees knock a hole downwind.
// · settle on the CALM day on a RUNNING clock (main.js's prep), not storm
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40).
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
// the settle transient into the storm peak.
// · setVenturi — the SITE's funnel, and the bug this clause exists for.
// A venturi lives in the site JSON, not the storm, so a sweep built only
// from stormDef silently drops it: main.js:424 calls setVenturi on the
// wind at every site load, and this tool did not. On the corner block —
// whose entire weather personality IS the funnel — that under-reports
// every corner load and hands back an easier yard than the one that
// ships. A false PASS, which is the SPRINT6 trap wearing the other face:
// there the tool called a good site unriggable, here it would call a
// mean site cheap. Both are the tool lying about a yard it can't see.
const trees = anchors.filter((a) => a.type === 'tree');
const wind = createWind(stormDef);
wind.setVenturi(venturi);
wind.setSheltersFromTrees(trees);
const calmWind = createWind(calmDef);
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
calmWind.setSheltersFromTrees(trees);
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
// is a bug some harness shipped:
// · windForSite — the one shared wind builder (C's helper): venturi from
// the SITE def + tree shelters, byte-for-byte main.js's site-load
// wiring. THREE harnesses independently mis-built site wind before it
// existed (this tool's Sprint-11 funnel-off audit, C's bench reading
// def.wind.venturi off the STORM def, D's first garden harness) — a
// fourth copy of the wiring is how there's a fifth bug.
// · `use` re-points the caller's world-wind proxy so LIVE tree-sway
// closures sample this sweep's storm (frozen sway under-read q4 by
// 0.22 kN on the $80 line — C's landmine 2).
// · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
// in the real game, so the attach transient flies under storm wind
// and its loads count (cheap steel genuinely dies "at the settle" —
// that's the storm's opening seconds, not a separate phase).
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = [];
for (const cnd of cands) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
for (let i = 0, n = Math.round(AUDIT.SETTLE_S / FIXED_DT); i < n; i++) {
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % AUDIT.PRE_GUST_S);
}
rig.resetPeaks(); // ← the storm starts HERE
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
const tiers = rig.corners.map((c) => ({
id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1,
tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1),
}));
//
// TWO prices per corner (C's margin rule):
// `tier` cheapest hardware that HOLDS the measured peak — what the
// old audit sold, and what a player gambling the knife edge
// actually buys;
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
// the price the margin rule trusts (demand ÷ (1 MARGIN)).
// A corner whose `tier` sits inside the margin band is `marginal`: it
// holds on this bench and "breaks in the game" (the residual's working
// rule). The row's clean price is what closing that gap costs.
const tiers = rig.corners.map((c) => {
const hint = c.anchor.ratingHint ?? 1;
const tier = tierFor(c.peakLoad, hint);
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
});
const unholdable = tiers.filter((c) => !c.tier);
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET });
const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
rows.push({ ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET });
}
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
const winners = rows.filter((r) => r.affordable);
// winners are lines the budget can buy at the CLEAN price (≥15% headroom on
// every corner); a line only affordable on knife-edge steel is the wild-night
// 91.9-FULL illusion and gets its own bucket + verdict code so no front-end
// can print PASS over it by accident.
const winners = rows.filter((r) => r.clean).sort((a, b) => a.cleanHw - b.cleanHw);
const marginalWinners = rows.filter((r) => r.affordable && !r.clean);
return {
cands, rows, winners,
cands, rows, winners, marginalWinners,
verdict: winners.length
? { ok: true, code: 'pass', best: winners[0] }
: { ok: false, code: 'unaffordable', best: rows[0] },
: marginalWinners.length
? { ok: false, code: 'marginal-only', best: marginalWinners[0] }
: { ok: false, code: 'unaffordable', best: rows[0] },
};
}

View File

@ -23,7 +23,8 @@
* collapse to the same peak loads and the strict inequality goes red.
*/
import { auditSweep } from './sweep.js';
import { AUDIT, auditSweep } from './sweep.js';
import { HARDWARE } from '../../web/world/js/contracts.js';
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
@ -49,13 +50,12 @@ const STORM = {
id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
const CALM = { id: 'sweep_selftest_calm', duration: 8, dir: Math.PI / 2, base: 3, gusts: null };
/** Centred on the bed, wide enough to swallow it, aligned with the storm. */
const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
const peaksOf = (venturi) => {
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, calmDef: CALM, venturi });
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, venturi });
assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test');
return rows[0].tiers.map((c) => c.peak);
};
@ -97,7 +97,7 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
// every PEAK stays byte-identical (the hint is a pricing fact, not physics —
// the sweep flies unbreakable audit hardware). Written to fail if the hint
// is dropped from sweep.js's tierFor call: the two runs collapse into one.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, calmDef: CALM, venturi: [] }).rows[0];
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }).rows[0];
const honest = sweep(ANCHORS);
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a)));
@ -119,4 +119,34 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
'an over-ceiling corner must land in unholdable, or the verdict lies');
});
test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => {
// SPRINT13, C's residual: even a corrected bench under-reads the real UI's
// peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game".
// The sweep must therefore refuse to call a knife-edge line a winner — the
// wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this
// (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8%
// headroom on a1's measured peak: still priced, still "holds" on paper,
// and the verdict must refuse to bless it. Delete the marginal logic from
// sweep.js and this goes red on the verdict code.
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] });
const honest = sweep(ANCHORS);
const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1');
assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1');
assert(honest.verdict.ok && honest.verdict.code === 'pass',
'fixture drift: the honest yard must be a clean pass or this test asserts nothing');
const RATED = HARDWARE.at(-1);
const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: hint, sway: a.sway } : a)));
const row = lied.rows[0];
const a1 = row.tiers.find((c) => c.id === 'a1');
assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`);
assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN,
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`);
assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal');
assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean');
assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only',
`the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`);
});
export const SWEEP_TESTS = TESTS;

View File

@ -34,6 +34,7 @@ import * as THREE from './vendor/three.module.js';
import { FIXED_DT } from './js/contracts.js';
import { loadStorm, createWind } from './js/weather.js';
import { createSkyFx } from './js/skyfx.js';
import { createDebris } from './js/debris.js';
const q = new URLSearchParams(location.search);
const stormName = q.get('storm') || 'storm_03b_earlybuster';
@ -65,9 +66,16 @@ slab.position.set(0, 1.5, -11);
scene.add(slab);
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
// gate 2.3: the viewer flies the ambient-leaf layer too — the sky stack and
// the yard's tell belong in the same eyeball test
const debris = createDebris({ wind, scene });
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
// Warm the ambient layer to the scrub point: leaves carry a few seconds of
// state (speed EMA + travel), so a viewer that spawns them cold at ?t=50
// shows an empty sky the real t=50 would not.
for (let wt = Math.max(0, t - 8); wt < t; wt += FIXED_DT) debris.step(FIXED_DT, wt, {});
addEventListener('keydown', (e) => {
if (e.code === 'Space') paused = !paused;
if (e.key === ']') t = Math.min(def.duration, t + 5);
@ -86,13 +94,13 @@ function frame(now) {
const wall = Math.min(0.1, (now - last) / 1000); last = now;
if (!paused && t < def.duration) {
acc += wall;
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); debris.step(FIXED_DT, t, {}); }
}
camera.rotation.set(0, yaw, 0, 'YXZ');
const ch = (def.events || []).find((e) => e.type === 'windchange');
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
+ `front ${sky.changeFront.toFixed(2)} leaves ${debris.leafCount}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
renderer.render(scene, camera);
}

View File

@ -52,6 +52,66 @@ export function createDebris(o = {}) {
const w = new THREE.Vector3();
const probe = new THREE.Vector3();
// ---------------------------------------------------------------- leaves
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3"
// — the event-driven crates are the drama, but nothing SELLS the gale
// between events. A handful of leaves streaming with the wind does, from
// 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose — the
// same threshold D keys the player's lean on (their table, lane/d), so the
// yard and the body start telling the same story at the same speed. Count
// stays single digits (cap 7); these are a tell, not a particle system.
//
// Deterministic: own seeded rng (so the leaf pool never shifts the piece
// rng sequence), distance accumulated over the fixed-dt stream, flutter
// pure in t. Same (dt, t) stream in, same leaves out.
const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold
const LEAF_MAX = 7; // "a handful" — single digits, capped
const LEAF_SPAN = 26; // m of downwind run before a leaf recycles
const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf);
const leafGeo = new THREE.PlaneGeometry(0.16, 0.09);
const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide });
const leafMeshes = [];
const leafSeed = [];
for (let i = 0; i < LEAF_MAX; i++) {
const m = new THREE.Mesh(leafGeo, leafMat);
m.visible = false;
if (scene) scene.add(m);
leafMeshes.push(m);
leafSeed.push({
lat: lrand() * 16 - 8, // lane across the wind, m
base: 0.35 + lrand() * 1.5, // ride height, m
off: lrand() * LEAF_SPAN, // where on the loop it starts
fl: 1.6 + lrand() * 1.6, // flutter frequency
ph: lrand() * 6.283,
});
}
let leafDist = 0; // m travelled downwind, accumulated over the dt stream
let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe
function stepLeaves(dt, t) {
probe.set(0, 1.6, 0);
wind.sample(probe, t, w);
const sp = Math.hypot(w.x, w.z);
leafEma += (sp - leafEma) * Math.min(1, dt / 2.5);
const n = leafEma < LEAF_START ? 0
: Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5));
leafDist += sp * 0.85 * dt; // leaves ride a little under the wind
const inv = sp > 1e-4 ? 1 / sp : 0;
const dx = w.x * inv, dz = w.z * inv;
for (let i = 0; i < LEAF_MAX; i++) {
const m = leafMeshes[i], s = leafSeed[i];
const vis = i < n && inv > 0;
m.visible = vis;
if (!vis) continue;
const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2;
const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1;
const x = dx * along - dz * lat;
const z = dz * along + dx * lat;
m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z);
m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph);
}
}
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
function placeholder(spec) {
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
@ -108,6 +168,11 @@ export function createDebris(o = {}) {
const debris = {
get pieces() { return pieces; },
/** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */
get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; },
/** Positions of the flying leaves — for asserts and D's judging. */
get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); },
/** Lane E's GLBs, once they land. name -> Object3D template. */
setModels(map) { Object.assign(models, map); return debris; },
@ -125,6 +190,7 @@ export function createDebris(o = {}) {
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'debris') spawn(ev, t);
}
stepLeaves(dt, t); // gate 2.3 — the ambient tell
}
const player = world.player;
@ -219,6 +285,10 @@ export function createDebris(o = {}) {
clear() {
for (const p of pieces) despawn(p);
pieces.length = 0;
// leaves are a pooled ambient layer, not spawned pieces: hide and rewind
// so the next night's stream starts from the same state every time
for (const m of leafMeshes) m.visible = false;
leafDist = 0; leafEma = 0;
},
};

View File

@ -373,6 +373,18 @@ export function createHud(d) {
const quad = new THREE.PlaneGeometry(1, 1);
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
const BAR_W = 0.9, BAR_H = 0.1;
/**
* When two corner bars count as "on top of each other", in clip space (NDC
* spans -1..1 across the viewport), and how far apart to stack them.
*
* Measured off the collision the QA pass actually hit CB1/CB2 on the carport
* beam, viewed from where a player stands to rig them. A bar is ~0.9 world
* units wide and scaled to hold constant screen size, so these are roughly
* "one bar wide, one line tall" and don't need to track the bar's dimensions.
* Y is the tighter of the two on purpose: labels stack vertically, so a pair
* side-by-side is already readable and only a vertical overlap is mush.
*/
const BAR_NDC_X = 0.10, BAR_NDC_Y = 0.045, BAR_STACK_Y = 0.34;
const bars = [];
function ensureBars(n) {
@ -430,6 +442,14 @@ export function createHud(d) {
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _cam = new THREE.Vector3();
// Corner-bar de-collision (SPRINT13). Scratch + a per-frame record of where
// each bar landed in clip space, so bars that typeset on top of each other can
// be stacked instead. Pooled rather than reallocated — this runs every frame —
// and each entry carries `live`, because a corner that has no position this
// frame must not leave last frame's ghost for the others to stack against.
const _ndc = new THREE.Vector3();
const _placed = [];
const placedSlot = (i) => (_placed[i] || (_placed[i] = { x: 0, y: 0, live: false }));
// --- helpers ------------------------------------------------------------
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
@ -602,7 +622,7 @@ export function createHud(d) {
const c = d.rig.corners[i];
const b = bars[i];
const p = cornerPos(i);
if (!p) { b.holder.visible = false; continue; }
if (!p) { b.holder.visible = false; placedSlot(i).live = false; continue; }
_p.set(p.x, p.y, p.z);
b.holder.position.copy(_p);
@ -615,7 +635,43 @@ export function createHud(d) {
// out there rather than in a corner of the screen. Clamped so it doesn't
// balloon when you walk right up to a corner to repair it.
const dist = _p.distanceTo(_cam);
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
const scale = Math.max(0.75, Math.min(3.2, dist / 7));
b.holder.scale.setScalar(scale);
/**
* Stack bars that would typeset on top of each other. [B's flag, SPRINT13]
*
* The QA pass: "CB1/CB2 died together and typeset on top of each other".
* The carport's two beam anchors are 2.82 m apart and the sail hangs
* between them, so from most of the yard their bars land in the same
* handful of pixels and they are exactly the pair most likely to blow
* together, because they share a beam and a 0.22 ratingHint. The one
* moment the HUD has to be legible ("both beam corners went, that's why
* the sail is on the lawn") was the one moment it turned to mush.
*
* Screen space, not world space: whether two labels collide is a fact
* about the camera, and two corners a metre apart are unreadable from
* the fence and perfectly clear from underneath. Compared at the anchor
* point rather than after nudging, so the test is order-independent and
* a stack of three can't chase itself up the screen. Deterministic
* corner index order, no clock and O() on n=4.
*
* The offset rides `scale` so the gap is constant on screen at any range,
* the same reason the bar itself is scaled.
*/
_ndc.copy(b.holder.position).project(d.camera);
const onScreen = _ndc.z < 1; // behind the camera projects to nonsense
let clash = 0;
if (onScreen) {
for (let k = 0; k < i; k++) {
const o = _placed[k];
if (!o || !o.live) continue;
if (Math.abs(_ndc.x - o.x) < BAR_NDC_X && Math.abs(_ndc.y - o.y) < BAR_NDC_Y) clash++;
}
}
const slot = placedSlot(i);
slot.x = _ndc.x; slot.y = _ndc.y; slot.live = onScreen;
if (clash) b.holder.position.y += clash * BAR_STACK_Y * scale;
// SPRINT12 — B's flag, actioned: the bar reads the EFFECTIVE rating,
// rating × ratingHint, because that's the number sail.js fails on now.

View File

@ -69,7 +69,22 @@ export const needsLadder = (anchor) => {
return anchor.type === 'house'; // site_01's shape
};
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
/**
* What to call the thing the ladder serves, in the player's own words.
*
* SPRINT13 pool (the integrator's QA pass): the place label hardcoded "the fascia" true on
* backyard_01, a lie on the corner block, where the bracket is a carport beam. Keyed on `type`,
* which has been a CHECKED enum since Sprint 11 (`validateSite` rejects an unknown one), so a new
* site can only arrive here with a type somebody typed on purpose.
*
* The fallback names the MECHANISM, not a guessed structure: an unmapped bracket reads "the bracket
* needs the ladder", which is vague but never false. Same instinct as `needsLadder` itself the
* thing that broke here was a rule keyed on site_01's spelling of the idea instead of the idea.
*/
const BRACKET_NOUN = { house: 'the fascia', carport: 'the carport bracket' };
const bracketNoun = (a) => BRACKET_NOUN[a?.type] ?? 'the bracket';
/** Where the player stands to work a bracket anchor: out from the wall, at the anchor's x. */
const STAND_OFF = 0.9; // m clear of the wall face
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
@ -95,6 +110,8 @@ export function createLadder(scene, world, interact, player) {
base: new THREE.Vector3(),
topY: 2.9, // overwritten from the GLB's ladder_top node
view: null,
dropped: false, // lying in the grass where it was lost (see the reconcile in update)
dropYaw: 0,
};
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
@ -134,6 +151,11 @@ export function createLadder(scene, world, interact, player) {
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
} else if (state.dropped) {
// Dropped, not stowed: it lies FLAT where it fell. The stowed pose is a slouch against the
// shed wall, and a slouch in open grass reads as a ladder leaning on thin air — which looks
// like a bug at exactly the moment the player is hunting for the thing.
state.view.rotation.set(Math.PI / 2, state.dropYaw, 0);
} else {
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
}
@ -169,6 +191,7 @@ export function createLadder(scene, world, interact, player) {
onDone: (p, t) => {
state.carried = true;
state.placedAt = null;
state.dropped = false;
p.pickUp('ladder', t);
syncView();
},
@ -185,15 +208,19 @@ export function createLadder(scene, world, interact, player) {
clip: 'PickUp',
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
// single most useful sentence in the game.
// Standing under a blown bracket holding a spare, "the fascia needs the ladder" is the single
// most useful sentence in the game — so it has to name the RIGHT structure, or it's the single
// most confusing one. It said "fascia" in the corner block until SPRINT13.
// ...and it must not lie about WHERE. "it's by the shed" is true until the wind takes it out
// of your hands, and then it's the game sending you to the wrong end of the yard mid-storm.
label: (p) => (p.carrying === 'ladder'
? `set the ladder under ${a.id}`
: 'the fascia needs the ladder — it\'s by the shed'),
? `set the ladder under ${a.id.toUpperCase()}`
: `${bracketNoun(a)} needs the ladder — ${state.dropped ? 'you dropped it out there' : "it's by the shed"}`),
canUse: (p) => p.carrying === 'ladder',
onDone: (p, t) => {
state.carried = false;
state.placedAt = a.id;
state.dropped = false;
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
a.pos.z + STAND_OFF);
p.carrying = null;
@ -239,6 +266,29 @@ export function createLadder(scene, world, interact, player) {
* player.js calls this each frame from the same input it reads for movement.
*/
update(dt, t, input) {
// SPRINT13 pool — "the dropped spare's whereabouts are unknown" is the small half of this.
// The player can lose the ladder without telling us: knockdown() calls drop() (player.sim.js),
// which nulls `carrying` and pushes an event nothing here listens for. state.carried stayed
// true forever, and every consequence of that is silent:
// · syncView hides the mesh (`visible = !carried`) → the ladder is invisible
// · ladder_take.pos() returns null while carried → nothing to walk to
// · ladder_take.canUse needs !state.carried → it can never be picked up again
// Blown over mid-carry and the ladder left the game, while ladder_place went on saying "it's
// by the shed". It wasn't. On the corner block that silently ends the site's whole thesis:
// cb1/cb2 are bracket work, and bracket work needs a ladder that no longer exists.
// The player's hands are the one source of truth, so reconcile against them every frame and
// let the ladder FALL where it was lost — the same instinct as STATES.shelter, which enters
// and leaves itself so a dropped release can't strand you. Fetching it is now a cost you can
// see and walk to, which is what the mechanic wanted from the knockdown in the first place.
if (state.carried && player.carrying !== 'ladder') {
state.carried = false;
state.placedAt = null;
state.dropped = true;
state.dropYaw = player.facing ?? 0;
state.base.set(player.pos.x,
world.heightAt ? world.heightAt(player.pos.x, player.pos.z) : 0,
player.pos.z);
}
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
syncView();
},

View File

@ -172,6 +172,8 @@ export class PlayerView {
this._axis = new THREE.Vector3();
this._qYaw = new THREE.Quaternion();
this._qPitch = new THREE.Quaternion();
this._leanAxis = new THREE.Vector3();
this._qLean = new THREE.Quaternion();
}
/** @returns {string[]} clip names that bound to at least one bone */
@ -208,6 +210,16 @@ export class PlayerView {
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
} else if (sim.lean > 1e-3) {
// Wind lean (SPRINT13): the same foot-pivot tip as the knockdown, but small and toward the
// wind's downwind bearing (leanDir) rather than the fall direction. The sim suppresses lean
// whenever pitch > 0, so these two never fight — this is the else branch by construction, not
// by luck. leanMaxRad is the whole tip; a knockdown's is π/2, so a full lean is ~a third of a
// fall. Yaw still applies underneath, so you lean while facing wherever you're walking.
this._leanAxis.set(sim.leanDir.z, 0, -sim.leanDir.x);
if (this._leanAxis.lengthSq() < 1e-8) this._leanAxis.set(1, 0, 0);
this._qLean.setFromAxisAngle(this._leanAxis.normalize(), sim.lean * sim.leanMaxRad);
this.root.quaternion.copy(this._qLean).multiply(this._qYaw);
} else {
this.root.quaternion.copy(this._qYaw);
}

View File

@ -100,6 +100,21 @@ export const TUNE = {
knockBleed: 2, // exposure drains this many × faster than it fills
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
// Wind lean (SPRINT13 gate 2.4). The QA pass: "the player stands bolt upright and unbothered" at
// 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen (a couch-shove
// and a bus-stop slouch), and proved the deeper reason a clip can't do this at all: _rotOnly drops
// Hips.quaternion from every clip at load (player.js:44), 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, same machine
// as the knockdown pitch: the sim owns an angle and a direction, the view tips the body by it.
// Keyed on windBase (the ~4 s EMA), NOT raw windSpeed — measured: raw strobes the posture 8-18×/min
// across the storms, windBase settles it to 0-2 changes per 90 s. Sustained wind = posture; gust is
// already the stumble/knock event. Bands land on the storm rating ladder (D's THREADS table): night
// 1 upright all night, 2-3 lean, 4-5 in the gale.
leanWindMin: 8.3, // m/s windBase (30 km/h — the speed C's leaves start at) before you lean at all
leanWindFull: 22, // m/s windBase for a full lean — past the wildnight median, shy of its 25 peak
leanMaxRad: 0.30, // rad (~17°) of torso tip at full lean. A brace-into-it, not a fall (pitch is 0.5π)
leanSecs: 0.6, // s to reach/leave the target lean — slow, so a lull visibly lets you straighten
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
// RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
@ -164,6 +179,8 @@ export class PlayerSim {
this.gust = 0; // windSpeed - windBase, clamped ≥0
this.pitch = 0; // 0 upright … 1 flat on the ground
this.knockDir = { x: 0, z: 1 }; // which way the body went down
this.lean = 0; // 0 upright … 1 full wind-lean (view reads this + leanDir)
this.leanDir = { x: 0, z: 1 }; // unit downwind — the way the wind is trying to push you
this.climbY = 0; // m above the ground; >0 means you're up a ladder
this.climbTarget = 0; // where ladder.js asked you to be
@ -180,6 +197,9 @@ export class PlayerSim {
get clip() { return STATES[this.state].clip; }
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
/** Radians of torso tip at a full wind-lean. Surfaced so PlayerView reads it, not TUNE. */
get leanMaxRad() { return this.tune.leanMaxRad; }
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
@ -381,6 +401,22 @@ export class PlayerSim {
const pstep = dt / T.pitchSecs;
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
// --- wind lean: the sim owns it exactly like pitch, so the view stays a pure reader and the lean
// is deterministic (dt,t). Off windBase, not ws, so a gust punches the stumble/knock event
// while the posture holds. Suppressed where a lean would fight another root pose: on your back
// (pitch owns the body), braced (TakeCover is its own hunch), or up the ladder (you're pinned
// to the rungs, not standing in the wind). It eases to 0 there rather than snapping. ---
const leanOK = this.pitch < 1e-3 && this.state !== 'shelter' && !up;
const leanSpan = Math.max(1e-3, T.leanWindFull - T.leanWindMin);
const wantLean = leanOK
? clamp((this.windBase - T.leanWindMin) / leanSpan, 0, 1)
: 0;
const lstep = dt / T.leanSecs;
this.lean = clamp(this.lean + clamp(wantLean - this.lean, -lstep, lstep), 0, 1);
// downwind is where the wind blows TOWARD; track it only while there's a wind to read, so a lull
// leaves the last bearing rather than snapping leanDir to a default and twisting the body.
if (ws > 0.1) { const inv = 1 / ws; this.leanDir.x = wx * inv; this.leanDir.z = wz * inv; }
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
// this guard the speed check would immediately re-state you to idle and drop you off. ---

View File

@ -12,8 +12,8 @@
* the bottom for the seam it will plug into.
*/
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
@ -161,10 +161,34 @@ export class RiggingSession {
return OK;
}
/**
* Put specific hardware on a rigged corner, paying the difference.
*
* THROWS on hardware that isn't in the shop, where every other failure here
* returns {ok:false}. The split is deliberate and it is about who made the
* mistake: 'not rigged' and 'not enough budget' are things a PLAYER does, and
* the UI tickers the reason. Hardware that isn't in HARDWARE is not reachable
* by any click the picking UI only ever cycles the shop's own list so it
* can only mean a caller passed the wrong thing, and returning {ok:false} for
* that just lets a bug walk.
*
* It already had: balance.test's own comment records "setHardware fails
* silently, cheap hardware stays on, p2/p4 tear off" a loadout that quietly
* kept its carabiners and then reported a cascade as if it were a finding.
* The SPRINT12 QA pass hit the same edge from the other side. A wrong tier is
* invisible in the result (the sim just runs, cheaper), so this is exactly the
* class of mistake that must not be ignorable.
*/
setHardware(anchorId, hw) {
if (!HARDWARE.includes(hw)) {
throw new TypeError(
`setHardware("${anchorId}", ${JSON.stringify(hw?.name ?? hw)}) — not a shop item. ` +
`Pass a HARDWARE entry (${HARDWARE.map((h) => h.name).join(' / ')}), not a name or a copy: ` +
'the shop compares by identity. A returned {ok:false} here would leave the old ' +
'hardware on the corner and the sim would run cheaper without telling anyone.');
}
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw;
return OK;
@ -189,7 +213,7 @@ export class RiggingSession {
return this.tension;
}
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
/** Buy (or sell back) spares in prep. `spares` is the count on the shed table. */
setSpares(n) {
n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST;
@ -198,6 +222,38 @@ export class RiggingSession {
return OK;
}
/**
* How many spares are still on the shed table. [D's ask, SPRINT13]
*
* The read half of the seam D found: interact.js gated the table pickup on
* `!player.carrying` alone and never looked at the count, so a player who
* bought ZERO walked off with unlimited free shackles mid-storm the whole
* "limited hands, two trips" economy the ladder's cost is balanced against was
* free on the spare side, live on the public deploy.
*/
get sparesRemaining() { return this.spares; }
/**
* Take one spare off the table. [D's ask, SPRINT13]
*
* The consume half. `spares` IS the count on the table, not a purchased total
* held elsewhere, so taking one decrements it and that quietly fixes a
* SECOND bug for free: main.js:720 already refunds `session.spares * SPARE_COST`
* as "a spare you never had to use", so before this you were refunded for
* spares you'd already spent. Decrementing here makes that line honest with no
* change to main.js. reset() zeroes the count between nights, so nothing
* carries.
*
* interact.js calls `canUse: () => !p.carrying && session.sparesRemaining > 0`
* and `onDone: () => session.takeSpare()` once A threads the session into
* wireYardActions D has the interact side ready.
*/
takeSpare() {
if (this.spares <= 0) return fail('no spares left on the table');
this.spares -= 1;
return OK;
}
/**
* Ring-order the picks by angle around their ground-plane centroid, so corner
* i of the cloth grid always maps to a neighbouring anchor. Without it,
@ -232,6 +288,66 @@ export class RiggingSession {
return p.hw.rating * (a?.ratingHint ?? 1);
}
/**
* What each corner carries in STILL AIR at the current tension, newtons.
* Null until four corners are picked a quad is the only thing that has a load.
*
* D's ask, SPRINT13 ("the first lesson always costs ~$60"): the tension dial
* has a static price and the panel never showed it. At 1.4 the standing load
* alone is ~1.4-1.5 kN/corner on a 46 quad, which is over every tier the
* shop sells except a rated shackle on an honest anchor so a drum-tight sail
* rips corners off during PREP, on the calm day, before a breath of wind. That
* reads fair when you can see it coming and daylight robbery when you can't.
*
* Still air, not "calm day": this is the number the DIAL owns. Gravity and
* tension only no gusts, no forecast, nothing that could be mistaken for a
* prediction about tonight. The storm multiplies this by ~3-8x and the panel
* says "standing" for exactly that reason.
*
* Cheap enough to call on a click: 15.6 ms of settle on a 10×10 grid (measured),
* and the result is cached by the caller on (picks, tension, fabric) hardware
* doesn't move the cloth, so cycling tiers, the most-clicked action in prep,
* never recomputes.
*
* 4 s of settle, not 2: the cloth arrives at rest through a damped oscillation,
* and at low tension 2 s still reads ~4% high on the way down (0.195 vs 0.188
* kN converged). 4 s is inside 1% everywhere measured and costs 8 ms more, once.
*
* Cross-checked against the game the honest way: on the corner block's real
* 46 carport quad this reports a worst corner of 1.41 kN at tension 1.4
* D measured "~1.4-1.5 kN/corner at 46 m²" in play, from the other side.
*/
standingLoads({ settle = 4.0 } = {}) {
if (this.picks.length !== MAX_CORNERS) return null;
const rig = new SailRig({ anchors: this.anchors, gridN: 10, porosity: this.fabric.porosity });
/**
* UNBREAKABLE hardware, deliberately and this is the whole subtlety.
*
* The preview measures the LOAD, which is a fact about geometry and the
* dial; it is not a rehearsal of tonight. Attach it with the player's real
* tiers and at tension 1.4 the corners genuinely rip during this very
* settle (D measured it: three go at t0.5 on the calm day) and a broken
* corner carries nothing, so the panel would print a serene `0.00 kN` for
* the exact corner about to take the night down. The first version of this
* did that and the selftest below caught it.
*
* So: measure the load unbroken, then let the PANEL compare it against the
* corner's real effective rating and say "RIPS AT REST". The number stays
* honest and the warning lands where the player can act on it.
*/
const UNBREAKABLE = { name: 'preview', cost: 0, rating: Infinity };
rig.attach(this.picks.map((p) => p.anchorId), Array(MAX_CORNERS).fill(UNBREAKABLE), this.tension);
// Still air. `sample` must honour the out-param — sail.js reuses one vector.
const stillAir = {
sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }),
rainMmPerHour: () => 0,
};
for (let i = 0, n = Math.round(settle / FIXED_DT); i < n; i++) rig.step(FIXED_DT, stillAir, i * FIXED_DT);
const out = {};
for (const c of rig.corners) out[c.anchorId] = c.load;
return out;
}
/** Everything the HUD needs to draw the prep panel, in one read. */
get summary() {
return {
@ -241,7 +357,16 @@ export class RiggingSession {
spares: this.spares,
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
canStart: this.canStart,
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
// `effRating` rides along with the bare one: D's panel-honesty gap — the
// weak-link arrow reasoned on rating × ratingHint while the printed kN was
// the bare steel, so a cold player saw four identical 1.2s and one
// unexplained arrow. The panel prints the effective number now; the bare
// rating stays for anyone who wants to show the steel's own spec.
corners: this.picks.map((p) => ({
anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost,
effRating: this._effRating(p),
hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1,
})),
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
@ -407,23 +532,63 @@ export async function createRiggingUI({
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
}
/**
* Standing load per corner, cached on (picks, tension). [D's ask, SPRINT13]
*
* The cloth doesn't care what steel hangs off it, so cycling hardware the
* most-clicked action in prep never recomputes. Only closing the quad or
* moving the dial does, which is exactly when the number changes.
*/
let standingKey = null, standingCache = null;
function standing() {
const key = `${session.picks.map((p) => p.anchorId).join(',')}|${session.tension.toFixed(2)}|${session.fabric.id}`;
if (key !== standingKey) { standingKey = key; standingCache = session.standingLoads(); }
return standingCache;
}
function refresh() {
if (!el) return;
const s = session.summary;
const load = standing();
// padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner
// block ships `tr1b` and the column broke the moment a second site existed.
const rows = world.anchors.map((a) => {
const pick = session.pickOf(a.id);
if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)}`;
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
// The EFFECTIVE rating, not the steel's spec — D's panel-honesty gap: the
// arrow reasoned on rating × hint while this column printed the bare kN,
// so a carport beam and a house post both read "1.2 kN" and only one of
// them was telling the truth. This is the number sail.js fails on.
const corner = s.corners.find((c) => c.anchorId === a.id);
const eff = (corner.effRating / 1000).toFixed(1);
// "1.2×0.22" — the hint is shown as the discount it is, so the arrow has
// a reason on the same line instead of being folded into a smaller number
// the player can't account for.
const hint = corner.hint !== 1 ? `×${corner.hint}` : '';
const st = load?.[a.id] != null ? `${(load[a.id] / 1000).toFixed(2)}` : '—';
const over = load?.[a.id] != null && load[a.id] > corner.effRating;
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${st.padStart(4)}/${eff.padEnd(4)}kN${hint.padEnd(6)} $${pick.hw.cost}` +
`${over ? ' !! RIPS AT REST' : weak}`;
});
const area = quadArea();
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
// the first lesson always costs ~$60". At 1.4 the standing load alone is
// over most of the shop. Said as a per-corner average because that's the
// number the DIAL owns; the per-corner column above is where you find which
// one is about to let go.
const vals = load ? Object.values(load) : [];
const avg = vals.length ? vals.reduce((x, y) => x + y, 0) / vals.length : 0;
const worst = vals.length ? Math.max(...vals) : 0;
const standingLine = vals.length
? `standing ≈${(avg / 1000).toFixed(2)} kN/corner at this tension (worst ${(worst / 1000).toFixed(2)}) — before any wind`
: null;
el.textContent = [
`PREP — rig four corners $${s.budget} left`,
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
'',
...rows,
...(standingLine ? ['', standingLine] : []),
'',
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
'click anchor: rig / cycle hw shift-click: remove',

View File

@ -156,6 +156,99 @@ test('setAnchors moves the session to a new yard and drops the old picks', () =>
return 'session followed the site; stale picks did not';
});
test('setHardware THROWS on hardware the shop does not sell', () => {
// SPRINT13, QA pass: it returned {ok:false} and callers that didn't check
// sailed on with the old tier still hanging. balance.test's own comment
// records the bite — "setHardware fails silently, cheap hardware stays on,
// p2/p4 tear off" — a cascade reported as a finding. A wrong tier is
// invisible in the result, so this one must not be ignorable.
const s = session();
s.rig('h1');
let threw = null;
try { s.setHardware('h1', { name: 'titanium', cost: 0, rating: 99999 }); }
catch (e) { threw = e; }
assert(threw, 'a hardware object that is not in HARDWARE was accepted silently');
assert(threw instanceof TypeError, `expected a TypeError, got ${threw?.constructor?.name}`);
assert(s.pickOf('h1').hw === CARABINER, 'the refused tier must not have been applied');
// A LOOKALIKE is the real trap: same shape, same numbers, wrong identity.
let threw2 = null;
try { s.setHardware('h1', { ...CARABINER }); } catch (e) { threw2 = e; }
assert(threw2, 'a copy of a real HARDWARE entry was accepted — the shop compares by identity');
// and the player-reachable failures stay {ok:false}, not throws
assert(s.setHardware('p3', RATED).ok === false, 'an unrigged corner should fail, not throw');
return 'unknown tiers throw; player-reachable failures still return {ok:false}';
});
test('standing load: the tension dial has a price, and the panel can see it', () => {
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
// the first lesson always costs ~$60" — at 1.4 the standing load ALONE rips
// corners off during prep, on the calm day. This is the number that makes
// that visible before the money is spent.
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
s.setTension(0.7);
const loose = s.standingLoads();
s.setTension(1.4);
const tight = s.standingLoads();
assert(loose && tight, 'a closed quad must have standing loads');
assert(Object.keys(tight).length === 4, `expected 4 corners, got ${Object.keys(tight).length}`);
for (const id of Object.keys(tight)) {
assert(Number.isFinite(tight[id]) && tight[id] > 0, `${id}: standing load ${tight[id]} is not a real load`);
// The whole point: drum-tight costs more at rest than loose does. If this
// ever ties, the dial is decoration and D's $60 lesson teaches nothing.
assert(tight[id] > loose[id],
`${id}: standing load at tension 1.4 (${tight[id].toFixed(0)} N) is not above tension 0.7 ` +
`(${loose[id].toFixed(0)} N) — the dial has no static price, so the prep readout is a lie`);
}
// still air means still air: no wind, no gust, nothing that could be mistaken
// for a forecast. Deterministic — same picks and dial, same number, always.
s.setTension(1.4);
const again = s.standingLoads();
for (const id of Object.keys(tight)) {
assert(Math.abs(again[id] - tight[id]) < 1e-9, `${id}: standing load is not deterministic`);
}
const avg = Object.values(tight).reduce((a, b) => a + b, 0) / 4;
return `tension 0.7 -> 1.4 lifts the standing load to ≈${(avg / 1000).toFixed(2)} kN/corner, repeatably`;
});
test('standing load is null until the quad is closed', () => {
const s = session();
assert(s.standingLoads() === null, 'no picks should have no standing load');
for (const id of ['h1', 'h3', 'p1']) s.rig(id);
assert(s.standingLoads() === null, 'three corners is not a sail — there is no load to read');
s.rig('p2');
assert(s.standingLoads() !== null, 'four corners is a sail and must report');
return 'null until four corners, then real';
});
test('spares are consumable and finite — you cannot take one you did not buy', () => {
// D's live-storm find, SPRINT13: interact.js handed out unlimited free spares
// because it never read the count. This is the count's half of the fix — a
// spare economy that can actually run dry.
const s = session();
assert(s.sparesRemaining === 0, 'a fresh session has no spares on the table');
assert(s.takeSpare().ok === false, 'took a spare that was never bought — the QA bug, in one line');
s.setSpares(2);
assert(s.sparesRemaining === 2, `bought 2, table shows ${s.sparesRemaining}`);
assert(s.takeSpare().ok, 'first spare should come off the table');
assert(s.takeSpare().ok, 'second spare should come off the table');
assert(s.sparesRemaining === 0, `table should be empty, shows ${s.sparesRemaining}`);
const dry = s.takeSpare();
assert(!dry.ok && /no spares/.test(dry.reason), `a third take must fail, got ${JSON.stringify(dry)}`);
// The refund half: main.js pays back "a spare you never had to use" as
// session.spares × SPARE_COST. A consumed spare must not still be refunded —
// so a taken spare has to leave the count, not just flip a used flag.
const s2 = session();
s2.setSpares(3);
s2.takeSpare();
assert(s2.spares === 2, `after taking 1 of 3, refundable count must be 2, is ${s2.spares}`);
return 'spares run dry, and a used spare is no longer refundable';
});
test('picks come back ring-ordered however you click them', () => {
const s = session();
// deliberately crossing order: two diagonals first

View File

@ -185,6 +185,39 @@ export class SailRig {
const ring = orderRing(picked);
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
/**
* A new sail is a new clock. [SPRINT13 D's pool nit, and it wasn't a nit]
*
* `this.t` is the sim's OWN fixed-step clock: step() burns ragged frame dt
* into SIM_DT chunks and samples the wind at this.t, which is the whole
* reason a 144 Hz browser and a fast-forwarded selftest produce byte-equal
* traces. But it was only ever zeroed in the constructor, and main.js builds
* ONE SailRig at boot and re-attach()es it every night while step() runs
* every frame in EVERY phase (storm, aftermath, the forecast card) the
* moment `rigged` is true.
*
* So night 2 opened its storm with the clock wherever night 1's aftermath
* left it, and sampled the storm curve from there. Measured on the wild
* night, p1..p4 with rated shackles:
*
* clock reset (as authored) p4 3.65 kN p2 2.78 p3 1.62 p1 0.98
* carried t=110 (night 2) p4 2.60 kN p2 1.92 p3 0.81 p1 0.54
*
* Every night after the first flew a storm 30-50% weaker than the one C
* authored past the end of its own baseCurve, so it read the tail breeze
* flat, with no build and no peak. Worse, the offset was however long the
* player spent reading the invoice, so it wasn't even the same wrong storm
* twice: a determinism break in a repo whose whole sim is built on not
* having one.
*
* Zeroed here rather than in main.js because attach() is the one door every
* caller comes through (boot, the picking adapter, balance.test, the audit),
* and commit attach game.advance() storm all land in a single keypress,
* so t=0 at attach IS t=0 at the first storm frame.
*/
this.t = 0;
this._acc = 0;
this.corners = ring.map((a) => ({
anchorId: a.id,
anchor: a,

View File

@ -317,6 +317,47 @@ test('determinism: variable frame dt matches fixed dt', () => {
return 'ragged frame times converge on the fixed-dt trace';
});
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
// night, and step() runs in EVERY phase once rigged — so the clock ran on
// through the aftermath and the forecast card, and night 2 opened its storm
// wherever night 1 left off, sampling the curve past its own end. The offset
// was however long the player read the invoice for, so it wasn't even the
// same wrong storm twice.
//
// Asserted as the PROPERTY that broke rather than `t === 0`: two identical
// nights on one rig must produce identical load traces. That stays true if
// the clock is ever reset somewhere else, and it goes red the moment the
// reset leaves attach() — which is the mutation-check (delete `this.t = 0`
// from attach and the second trace diverges on sample 0).
const trace = (r) => {
const w = makeStubWind({ seed: 7, stormLen: 30 });
const out = [];
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
return out;
};
const r = rig(HEIGHTS_HYPAR);
const night1 = trace(r);
// the aftermath: the sail is still up and still being stepped while the
// player reads their invoice. This is the frame budget that used to poison
// the next night.
const calm = makeStubWind({ seed: 7, stormLen: 30 });
for (let i = 0; i < Math.round(20 / SIM_DT); i++) r.step(SIM_DT, calm, i * SIM_DT);
r.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0); // night 2, same rig object
const night2 = trace(r);
assert(night1.length === night2.length, `night 1 traced ${night1.length} samples, night 2 ${night2.length}`);
for (let i = 0; i < night1.length; i++) {
assert(night1[i] === night2[i],
`night 2 diverged from night 1 at sample ${i}: ${night1[i]} vs ${night2[i]}` +
'the rig carried its clock across attach(), so night 2 is flying a different storm ' +
'(and one that depends on how long the aftermath was on screen)');
}
return `${night1.length} load samples identical across a re-attach — same storm both nights`;
});
test('tension dial changes load (drum tight shock-loads)', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);

View File

@ -23,6 +23,7 @@ const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
const WHITE = new THREE.Color(0xffffff);
const FLASH_COL = new THREE.Color(0xdfe8ff);
const SUN_STORM = new THREE.Color(0xc4cedb); // the noon disc gone slate (gate 2.1)
// ------------------------------------------------------------ rain shadow
/**
@ -190,6 +191,11 @@ function createHail(opts) {
step(dt, camPos, vel, intensity, size, shadow) {
const n = Math.floor(max * clamp01(intensity));
mesh.count = n;
// D's aftermath find (S13): count:0 with frustumCulled off and depthWrite
// off still draws instance 0's identity matrix — a ~5 cm always-on-top
// white sliver at the world origin, QA's "ghost near the shed table".
// visible is the honest gate; count alone is not.
mesh.visible = n > 0;
if (n === 0) return;
const s = 0.6 + size * 0.9; // bigger stones read bigger
m.makeScale(s, s, s);
@ -342,8 +348,11 @@ function cloudTexture(size = 256, seed = 7) {
// ---------------------------------------------------------------- audio
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
// everything is built lazily on unlock() and silently absent before it.
const MASTER_GAIN = 0.55;
function createAudio(seed = 1) {
let ctx = null, master = null;
let muted = false;
let windGain, windFilter, windHowl, howlGain;
let rainGain, rainFilter;
let gustGain, gustFilter;
@ -380,6 +389,15 @@ function createAudio(seed = 1) {
/** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
* got built a suspended context is still silent, so the HUD reports this. */
get state() { return ctx ? ctx.state : 'none'; },
/** A's M key (gate 3.3). A flag, not just a gain write, so muting works
* before unlock() has built the graph and survives it. */
setMute(on) {
muted = !!on;
if (master) master.gain.value = muted ? 0 : MASTER_GAIN;
},
get muted() { return muted; },
/** Real bus gain, for asserts — null until unlock() builds the graph. */
get masterGain() { return master ? master.gain.value : null; },
/** Current layer gains — for the HUD and for asserting the bed tracks wind. */
levels() {
if (!started) return null;
@ -399,7 +417,9 @@ function createAudio(seed = 1) {
ctx = new AC();
if (ctx.state === 'suspended') ctx.resume();
master = ctx.createGain();
master.gain.value = 0.55;
// honour a mute that landed BEFORE the first gesture built the graph —
// M on the splash screen must not be forgotten by the unlock
master.gain.value = muted ? 0 : MASTER_GAIN;
master.connect(ctx.destination);
noiseBuf = noiseBuffer(ctx);
@ -641,15 +661,25 @@ export function createSkyFx(o = {}) {
// fixed post-change instant — def + seed in, same wall out, every run.
const FRONT_LEAD = 12; // s before the change the wall starts rising
const FRONT_FADE = 8; // s after the swing completes for it to clear
const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126°)
const FRONT_ARC = 2.6; // radians of horizon the wall spans (~149°)
const FRONT_MAX_OP = 0.85;
const frontEvents = (def.events || [])
.filter((e) => e.type === 'windchange' && Number.isFinite(e.t))
.map((e) => ({ t0: e.t, over: e.over ?? 6, az: null }));
// Band from ~34° above the horizon down to it, centred (in local space) on
// azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0 sits at
// atan2(z,x) = π. rotation.y = π A then carries the centre to azimuth A.
const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20);
// Band from ~34° above the horizon down to ~12° BELOW it, centred (in local
// space) on azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0
// sits at atan2(z,x) = π. rotation.y = π A then carries the centre to A.
//
// SPRINT13 gate 2.2 — why the band overshoots the equator: the old band
// stopped AT the horizontal plane through the camera, but from in-yard eye
// height the ground's true horizon sits ~0.6° BELOW that plane (the dip),
// so a sliver of bright sky showed under the wall's base and the bank read
// as a floating slab — the QA sighting, reproduced on dev_skyfx before this
// fix. Overshooting to 12° buries the base behind the ground from any eye
// height that matters (it survives scale.y's 0.3 floor: 37 m compressed to
// 11 m is still 3.7° elevation, below the dip). The ground plane occludes
// the overshoot by depth, so nothing is drawn twice.
const frontGeo = new THREE.SphereGeometry(170, 24, 10, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.27);
{
// Soft edges via vertex alpha, or the band reads as a floating rectangle
// (checked by eye on dev_skyfx.html — the hard phi/theta cut was exactly
@ -657,11 +687,15 @@ export function createSkyFx(o = {}) {
// the arc ends and across the top, so it sits ON the horizon like a bank
// of weather instead of hanging in the sky like a screen.
// Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the
// horizon edge.
// (now sub-horizon) bottom edge.
// The 1.6 exponent is gate 2.2's other half: 0.75 left the outer tenth of
// the arc at ~0.36 alpha, which from in-yard eye height is the "hard
// vertical seam" the QA saw at the bank's ends. 1.6 holds the same solid
// core (the arc is wider to compensate) but takes the ends to <0.12.
const uv = frontGeo.attributes.uv;
const rgba = new Float32Array(uv.count * 4);
for (let i = 0; i < uv.count; i++) {
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75);
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 1.6);
const up = 1 - smoothstep(0.45, 1, uv.getY(i));
rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1;
rgba[i * 4 + 3] = across * up;
@ -695,6 +729,8 @@ export function createSkyFx(o = {}) {
fogFar: scene && scene.fog ? scene.fog.far : 0,
sun: sun ? sun.intensity : 0,
hemi: hemi ? hemi.intensity : 0,
sunColor: sun ? sun.color.clone() : null,
sunRadius: sun && sun.shadow ? sun.shadow.radius : 1,
};
const baseSky = (scene && scene.background && scene.background.isColor)
? scene.background.clone() : CALM_SKY.clone();
@ -706,16 +742,20 @@ export function createSkyFx(o = {}) {
}
let flash = 0; // decaying lightning brightness
let gradeNow = 0; // the storm grade, gate 2.1 — pure in t, see step()
let flashQueue = []; // {at, power} — double-strike
let lastTelegraph = null;
const camPos = new THREE.Vector3();
const w = new THREE.Vector3();
const fx = {
rain, audio, dome, shadow, hailShadow, front: frontMesh,
rain, hail, audio, dome, shadow, hailShadow, front: frontMesh,
get flash() { return flash; },
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
get hailAmount() { return hailAmt; },
/** 0..1 the storm grade driving sky/sun/shadow-softness (gate 2.1).
* storminess with a floor under the author's darkness dial; pure in t. */
get stormGrade() { return gradeNow; },
/** 0..1 — how far risen the change-front wall is (gate 3.4). Pure in t. */
get changeFront() { return frontLevel; },
/** Azimuth (radians, XZ) the front stands in the quarter the new wind
@ -792,6 +832,16 @@ export function createSkyFx(o = {}) {
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); },
/**
* A's M key (gate 3.3): mute the whole audio bus. main.js feature-detects
* this (`typeof sky.setMute === 'function'`) and only advertises M once it
* exists so this method appearing is what lights the key up. Safe at any
* time: before unlock it sets a flag the unlock honours, after unlock it
* writes the master gain directly.
*/
setMute(on) { audio.setMute(on); },
get muted() { return audio.muted; },
/**
* @param {number} dt
* @param {number} t storm time
@ -821,6 +871,16 @@ export function createSkyFx(o = {}) {
const speed = Math.hypot(w.x, w.z);
const intensity = wind.rainAt(t);
const storminess = clamp01(Math.max(intensity, speed / 26));
// SPRINT13 gate 2.1 — the STORM GRADE. The QA pass played the southerly at
// 65 km/h under a noon-blue sky, and the arithmetic says why: everything
// below multiplied the weather by the author's palette (`darkness`), so a
// darkness-0.5 storm at full blow only ever moved 0.35 toward slate — the
// author's dial could zero the weather's say. The grade gives the weather
// a floor: at full storminess even a darkness-0 sky goes half way to
// slate, while the wild night (0.94) keeps reading as the night it is.
// Same curve the rain already uses (storminess IS max(rain, wind)), pure
// in t, computed above the render gate so a test can read it headless.
gradeNow = storminess * lerp(0.5, 1, darkness);
// --- events: lightning + the ticker ---
for (const ev of wind.eventsBetween(t - dt, t)) {
@ -913,7 +973,7 @@ export function createSkyFx(o = {}) {
if (!rendering) return;
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
skyCol.copy(baseSky).lerp(target, gradeNow);
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
if (scene) {
if (scene.fog) {
@ -922,8 +982,18 @@ export function createSkyFx(o = {}) {
scene.fog.far = lerp(160, 55, storminess);
}
}
if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2;
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2;
if (sun) {
sun.intensity = lerp(original.sun, original.sun * 0.12, gradeNow) + flash * 2.2;
// The sun loses its noon edge two ways as the grade builds: the warm
// disc goes slate (colour), and the shadows go soft (radius — the
// renderer is PCFSoft, so radius is real blur, not a no-op). Softness
// keys on STORMINESS, not the graded value: overcast is overcast even
// under a light-palette storm, and razor-sharp noon shadows at 65 km/h
// were the QA sighting this whole block answers.
if (original.sunColor) sun.color.copy(original.sunColor).lerp(SUN_STORM, gradeNow * 0.85);
if (sun.shadow) sun.shadow.radius = lerp(original.sunRadius, 7, storminess);
}
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, gradeNow) + flash * 1.2;
dome.position.copy(camPos);
dome.material.opacity = storminess * 0.85;
@ -937,7 +1007,7 @@ export function createSkyFx(o = {}) {
// and the cloud texture is baked near-white. The crush is what makes night
// look like night. It stops at 0.78 on purpose — the yard has no lights in
// it yet, and a storm you can't see isn't a storm, it's a black screen.
const nightAmt = storminess * darkness;
const nightAmt = gradeNow;
dome.material.color.copy(WHITE)
.lerp(target, nightAmt * 0.92)
.multiplyScalar(1 - 0.78 * nightAmt);
@ -1015,7 +1085,11 @@ export function createSkyFx(o = {}) {
original.fog.far = original.fogFar;
}
}
if (sun) sun.intensity = original.sun;
if (sun) {
sun.intensity = original.sun;
if (original.sunColor) sun.color.copy(original.sunColor);
if (sun.shadow) sun.shadow.radius = original.sunRadius;
}
if (hemi) hemi.intensity = original.hemi;
rain.dispose();
hail.dispose();

View File

@ -27,12 +27,19 @@
import { SAIL_TESTS } from '../sail.selftest.js';
import { RIGGING_TESTS } from '../rigging.selftest.js';
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js';
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
export default async function run(t) {
for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
// site_audit's own sweep. SPRINT11: the tool was flying site_02 with the
// venturi switched off — the auditor needed an auditor. See sweep.selftest.js.
for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn);
// SPRINT13: the audit's garden engine (gardenfly.js) — browser-only, so the
// flights run here in the async prelude and the tests assert on the results
// (Suite.test cannot await; a.test's pinned-separation flight is the
// precedent). run() is async now — runAll awaits it.
const gardenflyTests = await buildGardenflyTests();
for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn);
}

View File

@ -28,6 +28,11 @@ import { SailRig } from '../sail.js';
import { createSkyFx } from '../skyfx.js';
import { createWind, loadStorm } from '../weather.js';
import { HARDWARE, FIXED_DT, START_BUDGET } from '../contracts.js';
// SPRINT13: the garden weights come from garden.js — the SAME module main.js
// flies — not from a local copy. The old `const GARDEN_DRAIN = 0.9 // main.js`
// here was the drift A's export exists to kill: a retune would have landed in
// the game and left this suite green against last sprint's numbers.
import { GARDEN_DRAIN, HAIL_WEIGHT as W_HAIL, RAIN_WEIGHT as W_RAIN } from '../garden.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
@ -35,9 +40,6 @@ const [CARABINER, SHACKLE, RATED] = HARDWARE;
const WIN = (hp, lost) => hp >= 50 && lost < 2;
/** main.js's CALM_STORM — what wind.use() hands the rig outside a storm, and so what settles it. */
const CALM_STORM = 'storm_01_gentle';
const GARDEN_DRAIN = 0.9; // main.js
const W_HAIL = 5.0; // main.js, decision 13 — integration weights
const W_RAIN = 0.25;
/**
* The yard is READ FROM world.js, never copied.

View File

@ -16,7 +16,8 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind, forecastLines, forecastFor, leadFor } from '../weather.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
import { loadSite } from '../world.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -136,6 +137,112 @@ export default async function run(t) {
'clear() replaced the pieces array instead of emptying it — B holds a reference');
});
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3";
// the crates are event drama, the leaves are the continuous tell. Threshold
// 8.3 m/s (30 km/h) matches D's lean threshold on purpose — the yard and the
// body start telling the same story at the same speed. Count stays single
// digits, they stream WITH the wind, and the layer is deterministic.
t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => {
const stub = (sp) => ({
seed: 5,
sample: (p, t2, o) => o.set(sp, 0, 0),
eventsBetween: () => [],
});
const calm = createDebris({ wind: stub(5) });
fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {}));
assert(calm.leafCount === 0,
`${calm.leafCount} leaves flying at 18 km/h — below the threshold the yard is still`);
const gale = createDebris({ wind: stub(13) });
fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {}));
assert(gale.leafCount > 0, 'no leaves at 47 km/h — the gale has no ambient tell');
assert(gale.leafCount <= 9, `${gale.leafCount} leaves — the plan says single digits`);
// they stream WITH the wind (+x here): over one step every leaf either
// moves downwind or recycles ~a span upwind; none drifts against it
const before = gale.leaves.map((pos) => pos.x);
gale.step(FIXED_DT, 8, {});
const after = gale.leaves.map((pos) => pos.x);
assert(before.length === after.length, 'leaf count strobed across one step');
for (let i = 0; i < before.length; i++) {
const d = after[i] - before[i];
assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`);
}
// deterministic: same seed, same (dt, t) stream, same leaves — bit for bit
const a = createDebris({ wind: stub(13) });
const b = createDebris({ wind: stub(13) });
fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); });
assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) =>
pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z),
'two identical (dt, t) streams produced different leaves');
// clear() rewinds the layer — next night starts from the same state
gale.clear();
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
});
// SPRINT13, the third time this repo learned it: the venturi lives in the
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
// in Sprint 13 — `def.wind.venturi` off a storm def LOOKS right and never
// fires, and the funnel-off bench called the $80 line 91.5 FULL on a night
// the real game scores it 39.8 TATTERED (D's live replay). windForSite is
// the shared builder that makes the mistake unmakeable; this assert is
// B's strictly-raises pin lifted onto it, with the REAL shipped funnel:
// drop the setVenturi line and the two winds collapse to equal reads.
const site02 = await loadSite('site_02_corner_block'); // awaited here: Suite.test() is sync
t.test('windForSite flies the SITE venturi — the shipped funnel strictly raises the throat wind', () => {
const def = storms.storm_02_wildnight;
const site = site02;
const vl = site.wind?.venturi ?? [];
assert(vl.length > 0, "site_02 lost its venturi — this test (and the yard's weather) proves nothing");
const bare = createWind(def); // storm def alone — the trap itself
const funnelled = windForSite(def, site);
const throat = new THREE.Vector3(vl[0].x, 1.5, vl[0].z);
const a = new THREE.Vector3(), b = new THREE.Vector3();
let bareMean = 0, funMean = 0, n = 0;
for (let time = 20; time <= 80; time += 0.5) {
bare.sample(throat, time, a);
funnelled.sample(throat, time, b);
bareMean += Math.hypot(a.x, a.z); funMean += Math.hypot(b.x, b.z); n++;
}
bareMean /= n; funMean /= n;
assert(bareMean > 3, `the storm never blew at the throat (${bareMean.toFixed(1)} m/s) — vacuous`);
assert(funMean > bareMean * 1.1,
`throat wind ${funMean.toFixed(2)} vs bare ${bareMean.toFixed(2)} m/s — the site's venturi is not `
+ 'reaching windForSite\'s wind (it must call setVenturi the way main.js does at every site load)');
});
// D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with
// frustum culling and depthWrite both off still draws instance 0's identity
// matrix — a ~5 cm always-on-top white sliver at the origin, the QA's "ghost
// near the shed table". The gate is mesh.visible, not count. This flies the
// wild night and demands the pool is invisible whenever no hail is falling
// and visible whenever it is — both directions, whole storm.
t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx({ scene, camera, wind });
let sawHail = false, sawCalm = false;
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
sky.step(dt, time, {});
// the mesh draws floor(1300 × intensity) stones, so a burst's first
// millisecond can honestly show zero — demand visibility only once the
// intensity buys at least one stone, and invisibility only at exactly 0
if (sky.hailAmount >= 1 / 1300) {
sawHail = true;
assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`);
} else if (sky.hailAmount === 0) {
sawCalm = true;
assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws — the sliver`);
}
});
assert(sawHail && sawCalm, 'storm never exercised both states — this test proves nothing');
sky.dispose();
});
// Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
// They verified sun/hemi restore exactly and spotted that fog didn't; this pins
// both. The vacuity guards matter — a restore test where nothing ever moved is
@ -172,6 +279,86 @@ export default async function run(t) {
`skyfx left ${scene.children.length - before.children} object(s) in the scene`);
});
// SPRINT13 gate 3.3 — A's M key. main.js feature-detects sky.setMute and only
// advertises M once it exists, so this contract is what lights the key up.
// The pre-unlock case is the one that bites: M pressed on the splash screen,
// BEFORE the first gesture builds the audio graph, must survive the unlock.
t.test('M-mute: setMute silences the bus, and a pre-unlock mute survives unlock', () => {
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx({ wind }); // headless — the bus needs no scene
assert(typeof sky.setMute === 'function', "no setMute — A's M key has nothing to call");
assert(sky.muted === false, 'a fresh sky must not start muted');
sky.setMute(true); // before unlock — the splash case
assert(sky.muted === true, 'setMute(true) did not take');
sky.unlockAudio();
if (sky.audio.ready) { // no AudioContext = nothing to silence
assert(sky.audio.masterGain === 0,
`unlock forgot a pre-unlock mute: master at ${sky.audio.masterGain}`);
sky.setMute(false);
assert(sky.audio.masterGain > 0, 'unmute left the master gain at 0');
}
sky.dispose();
});
// SPRINT13 gate 2.1 — the storm grade. The QA sighting: 65 km/h + driving
// rain under a noon-blue sky with razor midday shadows. Cause: sky and sun
// both multiplied the weather by the author's palette (`darkness`, 0.5 on
// the southerly), so the dial could zero the weather's say — and nothing
// touched shadow softness at all. Pins: the grade floors the darkness dial,
// the sun dims/goes slate/softens, and dispose hands all of it back.
t.test('storm grade: a mid-darkness gale dims the sun, softens shadows, restores on dispose', () => {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e8);
const camera = new THREE.PerspectiveCamera();
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
const before = { sun: sun.intensity, color: sun.color.getHex(), radius: sun.shadow.radius };
// a darkness-0.5 storm blowing 18 m/s (65 km/h) in full rain — the QA scene
const gale = {
seed: 1, def: { sky: { darkness: 0.5 } },
sample: (p, t2, o) => o.set(18, 0, 0),
speedAt: () => 18, rainAt: () => 1, rainMmPerHour: () => 30,
gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {},
};
const sky = createSkyFx({ scene, camera, wind: gale, sun });
fixedLoop(10, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
assert(sky.stormGrade > 0.7,
`grade ${sky.stormGrade} — the darkness dial still zeroes the weather (the old formula reads 0.50 here)`);
assert(sun.intensity < before.sun * 0.5,
`sun at ${sun.intensity.toFixed(2)} of ${before.sun} — still noon at 65 km/h`);
assert(sun.shadow.radius > 4,
`shadow radius ${sun.shadow.radius} — razor-sharp midday shadows in a gale`);
assert(sun.color.getHex() !== before.color, 'the sun disc never lost its noon warmth');
sky.dispose();
assert(sun.intensity === before.sun && sun.color.getHex() === before.color
&& sun.shadow.radius === before.radius,
'dispose did not hand the sun back exactly (intensity / colour / shadow radius)');
});
// SPRINT13 gate 2.2 — the wall's edges, judged from in-yard eye height.
// Two QA sightings, both geometry: a sliver of bright sky under the bank's
// base (it stopped AT the camera's horizontal plane, above the ground's true
// horizon), and a hard vertical seam at the arc ends (0.36 alpha at the
// outer tenth). The band now overshoots the equator and the end fade is
// steeper; these pins are what "grounded" and "feathered" mean in numbers.
t.test('the change-front wall grounds below the horizon and feathers its ends', () => {
const wind = createWind(storms.storm_03_southerly);
const sky = createSkyFx({ wind });
const geo = sky.front.geometry;
geo.computeBoundingBox();
assert(geo.boundingBox.min.y < -20,
`front base at y=${geo.boundingBox.min.y.toFixed(1)} — a band stopping at eye level floats a sky sliver under the wall`);
const uv = geo.attributes.uv, col = geo.attributes.color;
assert(col && col.itemSize === 4, 'front lost its vertex alpha — the edges are hard cuts again');
let edgeMax = 0;
for (let i = 0; i < uv.count; i++) {
const u = uv.getX(i);
if (u <= 0.09 || u >= 0.91) edgeMax = Math.max(edgeMax, col.getW(i));
}
assert(edgeMax < 0.2,
`arc-end alpha ${edgeMax.toFixed(2)} — the vertical seam QA saw at the bank's ends`);
sky.dispose();
});
// --- SPRINT2 §Lane C.3: rain has to stop at the cloth ---
// Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing
// under test is the projection, and a flat panel makes the right answer

View File

@ -16,7 +16,7 @@ import { Interact, wireYardActions } from '../interact.js';
import { createBroom, BROOM_TUNE } from '../broom.js';
// The REAL rule, not the fakeLadder's hand-copied duplicate of it — a rule the suite re-implements
// is a rule the suite cannot catch drifting. (ladder.js is headless-importable for this reason.)
import { needsLadder as realNeedsLadder } from '../ladder.js';
import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
@ -175,6 +175,54 @@ export default async function run(t) {
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
});
// ---------------------------------------------------------------- wind lean (SPRINT13 gate 2.4)
t.test('lean: calm leaves you upright, wind tips you, and it scales with the sustained speed', () => {
const calm = new PlayerSim();
drive(calm, 20, {}, windX(TUNE.leanWindMin - 3)); // below the threshold, all storm
assertLess(calm.lean, 1e-3, 'under leanWindMin you stand up straight');
const mid = new PlayerSim();
drive(mid, 40, {}, windX((TUNE.leanWindMin + TUNE.leanWindFull) / 2));
assert(mid.lean > 0.35 && mid.lean < 0.65, `mid wind is a partial lean, got ${mid.lean.toFixed(2)}`);
const gale = new PlayerSim();
drive(gale, 40, {}, windX(TUNE.leanWindFull + 6));
assertClose(gale.lean, 1, 1e-2, 'past leanWindFull you are fully into it');
assert(gale.lean >= mid.lean, 'more sustained wind is more lean');
});
t.test('lean: keys on the SUSTAINED base, not the gust — a single gust does not snap you over', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(4)); // learn a calm baseline: no lean
const before = s.lean;
drive(s, 1.2, {}, windX(TUNE.leanWindFull + 10), 30); // one big gust, shorter than leanSecs momentum
// windBase barely moves in 1.2 s (baseTrack ~4 s memory), so the posture must not jump to full.
assertLess(s.lean, 0.5, `a brief gust is a stumble, not a lean — got ${s.lean.toFixed(2)} from ${before.toFixed(2)}`);
});
t.test('lean: it points downwind, and it is suppressed on your back, braced, or up a ladder', () => {
// downwind: wind blowing +X should tip leanDir toward +X.
const s = new PlayerSim();
drive(s, 30, {}, windX(TUNE.leanWindFull + 5));
assert(s.lean > 0.5, 'leaning');
assertClose(s.leanDir.x, 1, 1e-6, 'leanDir points the way the wind blows (x)');
assertClose(s.leanDir.z, 0, 1e-6, 'and not sideways (z)');
// braced: holding C must zero the lean — TakeCover is its own pose.
const braced = new PlayerSim();
drive(braced, 30, { shelter: true }, windX(TUNE.leanWindFull + 5));
assertEq(braced.state, 'shelter', 'C braces');
assertLess(braced.lean, 1e-2, 'and a brace is not a lean');
// knocked down: pitch owns the body, lean eases out.
const floored = new PlayerSim();
drive(floored, 30, {}, windX(TUNE.leanWindFull + 5));
assert(floored.lean > 0.5, 'leaning before the fall');
floored.knockdown(0, 1, 0);
drive(floored, 1, {}, windX(TUNE.leanWindFull + 5));
assertLess(floored.lean, 1e-2, 'flat on your back is not a lean');
});
// ---------------------------------------------------------------- knockdown
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
const brief = new PlayerSim();
@ -448,6 +496,74 @@ export default async function run(t) {
'nor a tree limb — that is a strop you throw');
});
// --- SPRINT13 pool: the real createLadder, not the fake. These two pin the things the QA pass
// found by playing, and both were silent — no error, no red, just a mechanic that stopped.
// (createLadder is scene-free with scene=null: the view is the only THREE it needs.)
const realLadderWorld = () => ({
anchors: [
{ id: 'h2', type: 'house', work: 'bracket', pos: { x: 0, y: 2.48, z: -9.95 } },
{ id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -8.4, y: 2.29, z: -3 } },
],
shedTable: { pos: { x: 9, y: 0.9, z: 6 } },
heightAt: () => 0,
});
t.test('ladder: a knockdown mid-carry drops it in the grass — it does not leave the game', () => {
const world = realLadderWorld();
const interact = new Interact();
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
const ladder = createLadder(null, world, interact, player);
const takeTarget = [...interact.targets.values()].find((x) => x.id === 'ladder_take');
// in your hands, walking it out to the bracket — through the target's OWN onDone, because
// player.pickUp alone fills your hands without telling the ladder, which is not a state the
// game can produce and would test the fixture rather than the code.
takeTarget.onDone(player, 0);
ladder.update(DT, 0, {});
assert(ladder.carried, 'carried');
assertEq(player.carrying, 'ladder', 'and the hands agree');
assertEq(takeTarget.pos(), null, 'nothing to walk to while it is in your hands');
// the wind takes you over at (4, 2). knockdown() -> drop() nulls carrying, and before SPRINT13
// nothing told the ladder: it stayed "carried" by nobody, invisible and un-takeable forever.
player.knockdown(0, 1, 0);
assertEq(player.carrying, null, 'the knockdown took it out of your hands');
ladder.update(DT, 0, {});
assert(!ladder.carried, 'the ladder knows it is no longer being carried');
const p = takeTarget.pos();
assert(p, 'and it is somewhere you can walk to');
assertClose(p.x, 4, 1e-6, 'it fell where you did (x)');
assertClose(p.z, 2, 1e-6, 'it fell where you did (z)');
assert(takeTarget.canUse({ carrying: null, climbY: 0 }),
'and you can pick it up again — this is the assert that fails if the reconcile is reverted');
});
t.test('ladder: the place prompt names the structure it is under, and where the ladder is', () => {
const world = realLadderWorld();
const interact = new Interact();
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
const ladder = createLadder(null, world, interact, player);
const placeAt = (id) => [...interact.targets.values()].find((x) => x.id === `ladder_place_${id}`);
const empty = { carrying: null };
// QA pass: this said "the fascia" while you stood under a carport beam.
assert(/fascia/.test(interact.labelOf(placeAt('h2'), empty)), 'the house bracket IS the fascia');
assert(/carport/.test(interact.labelOf(placeAt('cb1'), empty)),
`a carport beam is not a fascia — got "${interact.labelOf(placeAt('cb1'), empty)}"`);
assert(!/fascia/.test(interact.labelOf(placeAt('cb1'), empty)),
'and it must not call it one');
// ...and it must not lie about where the ladder is once the wind has taken it.
assert(/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)), 'stowed: it is by the shed');
[...interact.targets.values()].find((x) => x.id === 'ladder_take').onDone(player, 0);
ladder.update(DT, 0, {});
player.knockdown(0, 1, 0);
ladder.update(DT, 0, {});
assert(!/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)),
'dropped: the shed is exactly where it is NOT');
});
t.test('ladder: keys on the MECHANISM a site declares, not on the anchor type', () => {
// SPRINT10 gate 1. The failure mode is the point: when needsLadder says false, interact's
// canReach returns TRUE UNCONDITIONALLY — so a mis-keyed anchor doesn't error, it silently

View File

@ -112,6 +112,33 @@ export async function loadStorm(name, dir = STORM_DIR) {
return def;
}
/**
* The wind a YARD actually flies storm def + the SITE's local effects,
* wired exactly as main.js does at every site load (setVenturi from
* siteDef.wind.venturi, tree shelters from the anchors). SPRINT13:
*
* THREE harnesses have now measured site_02 with the funnel switched OFF by
* building wind from the storm def alone B's site_audit (Sprint 11, their
* sweep.selftest tells the story), C's garden_bench (Sprint 13, where it
* flipped the $80-line headline: 91.5 FULL funnel-off vs 39.8 TATTERED in
* D's live replay), and probe4, which never attempted it. The venturi lives
* in the SITE json, not the storm, so `def.wind.venturi` off a storm def
* LOOKS right and never fires. Any tool measuring a yard builds its wind
* HERE, or it is measuring a yard that doesn't ship. The strictly-raises
* assert in c.test.js goes red if the setVenturi line is ever dropped.
*
* @param {object} stormDef parsed storm JSON
* @param {object} [siteDef] parsed site JSON (loadSite) its wind.venturi list
* @param {Array} [anchors] world anchors; trees become wind shelters
* @param {object} [opts] forwarded to createWind ({seed})
*/
export function windForSite(stormDef, siteDef, anchors = [], opts = {}) {
const wind = createWind(stormDef, opts);
wind.setVenturi(siteDef?.wind?.venturi ?? []);
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
return wind;
}
/**
* @param {object} def parsed storm JSON
* @param {object} [opts] {seed} same seed + same def = same storm, every run