Compare commits

...

15 Commits

Author SHA1 Message Date
type-two
ed62c7d4be Sprint 15 integration: merge all lanes (418/0/0), apply E's world.js manifest patch, [I] entry 2026-07-20 19:38:11 +10:00
type-two
e727e49325 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-20 19:30:45 +10:00
type-two
a79f714c86 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-20 19:30:45 +10:00
type-two
82691678eb Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-20 19:30:45 +10:00
type-two
91266e2a99 Lane D S15: gates 2.3 + 1.4 judged and closed — the editor is iterative (three-position bed study, receipts), the honest line replays S14 to the decimal on the scratch merge (418/0/0), pergola sentence filed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:28:35 +10:00
type-two
ef6b3d0028 gate 2.2 closed: adopt C's converged storm store byte-identical; score panel becomes a view of it; [B] THREADS closing entry (pins reproduced, timings, seam receipts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:32:07 +10:00
type-two
0bb0e9eb6d Lane C S15: converge gate 2.2 onto B's THREADS proposal — B's filename/global/source/default taken, storm+time API kept; THREADS: seam receipt + gate-1.3 storm-side zero-delta confirmation
editor_stormsel.js -> editor_storm.js (B's name); globalThis.EDITOR_STORM
(B's name); setStorm/setTime carry B's source tag in the payload; default
becomes B's storm_03_southerly at my receipted t=40 (the pin stays pinned
in c.test — it never needed to be the default, and the c.test assert now
pins the CONVERGED pair instead so a private default cannot regrow).

Zero-delta second harness: envelope.html week mode, both shipped yards,
all five nights vs my S13 tables — byte-identical everywhere (site_02 x
earlybuster all 11 rows; backyard h1-h3 Pa/hint 266/946-1037/2093-2505/
1844-1933; branch ladder 0.88/0.76; t1 southerly 15.45). Measured on
lane/c @ gate-2.2 commit atop main c182fc1, pre-E-re-bake; zero expected
under the 1.00 ruling, zero measured, nothing to flag.

Selftest 413/0/0; mutations 6 (default drift) and 7 (source dropped)
red-then-green on top of the five in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:23:31 +10:00
type-two
d6f04d5e43 editor: one storm picker + a progress line that paints (S15 gates 2.1/2.2, B's halves)
editor_storm.js owns THE storm selection and THE storm list; SCORE IT's
dropdown is a view of it (default = southerly, C's old default, kept on
purpose). The score panel renders scoreSite's progress ticks — flight x/y,
elapsed — and audit.html gets the same line. C's half: subscribe
editor.wind.js per the THREADS proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:19:44 +10:00
type-two
b1d2d2a8e6 score: the sweep gets a heartbeat — chunked driver yields between flights, same numbers to the byte (S15 gate 2.1)
findCandidates/priceCandidate/judgeSweep extracted so the sync auditSweep and
the new auditSweepAsync are ONE copy of the math; scoreSite drives the async
path with onProgress ticks (sweep/fly/separation phases). Yield is a
MessageChannel task, not setTimeout — Chrome's intensive timer throttling
turned an occluded run into one flight per minute, measured. Three new
asserts, mutation-checked red-then-green in one sitting (impure chunk + a
swallowed tick = all three red with the intended diagnostics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:19:44 +10:00
type-two
3a2e4ff5e1 Lane C S15 gate 2.2: ONE storm picker — editor_stormsel.js is the shared storm+time selection; wind panel becomes a view of it + JUMP TO WORST SECOND
The store: one owner for 'which storm, which second' on the editor page.
Garbage keys refused loudly, no-op writes silent (the anti-ring guard that
lets a subscriber clamp from inside its own handler), listener errors
contained, default = the gate-2.3 pin (wildnight @ 60 s). editor.wind.js
drops its private stormKey/time and re-exports the store's STORM_KEYS —
one list, same array. B's editor_score.js patch proposed in THREADS.

Polish (my call): JUMP TO WORST SECOND — argmax of speedAt over grid ×
storm, venturi throats passed as exact probes, dt = the scrubber's own
0.5 s step. Writes the SHARED second. site_02 × wildnight: 51.11 m/s
@ 60.5 s at (-7.5, 0), the funnel plateau, found by scan not patience.

Selftest 413/0/0 (410 + 3). Five mutations red-then-green: no-op guard
off, validation off, singleton doubled, comparator flipped, probes
dropped — each reds its own assert with its own message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:05:33 +10:00
type-two
cebc14e2b1 Lane E S15: THREADS — gate 1.2 receipts, the exact world.js patch for the integrator (leadFor precedent), ruling constraint satisfied by construction, palette proposals for the next yard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:49:38 +10:00
type-two
2a540523ff Lane E S15 gate 1.2: RULE 4 pins — manifest===GLBs both directions (4a), runtime===manifest across all three shipped sites (4b); compared against the manifest, never a literal; both mutation-checked red-then-green (post 1.0->0.9)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:49:38 +10:00
type-two
8aa49528e3 Lane E S15 gate 1.2: ratings.js — factoryExtras(type), the runtime end of the manifest; refuses to guess for ambiguous types, warns once
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:49:38 +10:00
type-two
e46ff18648 Lane E S15 gate 1.2: re-bake sail_post top_anchor 0.90 -> 1.00 per the gate 1.1 ruling; factory emits the per-type anchor ratings manifest (anchor_ratings.gen.js) from the re-imported GLBs, ambiguity-aware, --only-guarded; asset_report carries per-asset anchor extras
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:49:38 +10:00
type-two
d8e2ecea80 [B] gate 1.3 zero-delta table (all three yards, 0 deltas @ c182fc1) + gate 2.2 storm-picker seam proposal for C
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:47:55 +10:00
16 changed files with 1720 additions and 117 deletions

View File

@ -6903,3 +6903,482 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
Not touched, per the sprint's own sequencing: gate 1.2/1.3/1.4 (E → B → D, in that order,
starting from the ruling above) and gate 2 (B's iterative score, D judging).
[E] 2026-07-20 — 🔩 **GATE 1.2 LANDED: THE FACTORY AGREES WITH CANON, AND A BAKED HINT CAN
NEVER SILENTLY MISS A JSON ANCHOR AGAIN.** Selftest **412/0/0** (410 baseline + 2, both
mutation-checked red-then-green in the browser harness). My half is on lane/e; the
world.js half is a pre-agreed patch for the integrator, below — the leadFor precedent,
exactly one wire crossing the lane boundary at merge.
**1 · THE RE-BAKE (the ruling, taken).** `sail_post.top_anchor` now bakes
`rating_hint 1.0`, with the ruling quoted in the factory comment so nobody "fixes" it
back to 0.90 without re-opening gate 1.1. Full factory run, and the receipts:
· **only `sail_post_v1.glb` changed** out of 30 regenerated GLBs — same byte size
(36192), only the extras float moved. Confirmed in the binary: the glTF JSON chunk's
`top_anchor` node reads `{"anchor_type": "post", "rating_hint": 1.0}`.
· **Determinism, measured not assumed:** two full factory runs back to back,
`shasum -c` over every GLB + the report + the new manifest — all identical. Even
`contact_sheet.png` re-rendered byte-identical.
· Render checked: sail_post thumb still a 4.03 m post beside the 1.7 m capsule, 528 tris.
**2 · THE MECHANISM (the actual bug, buried).** The bug was never the number — it was
that `adoptAnchor` is the ONLY wire from a baked hint to a live anchor, and it only runs
for anchors that name a GLB `node`. Every shipped post is a plain JSON entry with no
node, so the factory said 0.90, `?? 1` said 1.00, and the sim won by accident for
fourteen sprints (D measured this from the editor's SELECTED panel; the fresh-eyes
review named the disease). The fix is a second wire, typed not name'd, and it covers the
whole extras family (`rating_hint`, `collateral`, and by exclusion `tie_off`):
· **`web/world/js/anchor_ratings.gen.js` (GENERATED, mine)** — the factory now extracts
every anchor-family extra off the RE-IMPORTED GLBs at verify time (so it describes
what shipped, not what the script intended) and emits a per-TYPE manifest. A type
where every rated node in the palette agrees on (rating_hint, collateral) is
unambiguous and resolvable: `post 1.0`, `house 0.35/gutter`, `carport 0.22/carport`,
`carport_post 0.30/carport`, `swing_frame 0.45/swing_set`. A type whose nodes
disagree (`tree`: 1.0 → 0.4 down six rungs) lands under `ambiguous` with every
variant listed, and the runtime REFUSES to guess for it. `--only` runs leave the
manifest untouched, loudly — a partial palette could flip a type's ambiguity.
`tools/blender/asset_report.json` also carries the per-asset `anchors` blocks now,
as receipts.
· **`web/world/js/ratings.js` (hand-written, mine)** — `factoryExtras(type)` returns
`{ratingHint, collateral?}` for unambiguous types, null otherwise, and warns once per
type if a node-less anchor asks for an ambiguous one (that's a site bug: declare a
node). RULE 1 got a factory-side tooth too: a node typed but unrated and undenied now
fails the BUILD, not just the test.
· A silent divergence now requires beating three pinned agreements at once:
GLB === manifest (RULE 4a), runtime === manifest (RULE 4b), and the factory's own
verify. Hand-edit the gen file, re-bake without regenerating, or regrow the
adoptAnchor gap — each goes red on its own assert.
**3 · THE WORLD.JS PATCH — integrator, this is yours to apply at merge, verbatim.
Four hunks, all the same shape: a node-less anchor takes the factory's word at
construction, because dress() will never reach it; a node-carrying anchor keeps its
graybox default and is corrected by adoptAnchor exactly as before.**
Hunk 1 — imports (after the contracts.js import, ~line 15):
```js
import { factoryExtras } from './ratings.js';
```
Hunk 2 — house anchors (~line 350):
```js
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
{ work: a.work },
a.node ? null : factoryExtras(a.type ?? 'house'),
));
```
Hunk 3 — posts (~line 464):
```js
anchors.push(Object.assign(
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
{ work: spec.work },
spec.node ? null : factoryExtras(spec.type ?? 'post'),
));
```
Hunk 4 — structure anchors (~line 533):
```js
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
{ work: a.work },
a.node ? null : factoryExtras(a.type ?? 'post'),
));
```
Tree anchors need no hunk: they always declare nodes in every shipped site, and `tree`
is ambiguous so `factoryExtras` would return null anyway — but if A prefers symmetry,
the same one-liner on the makeSwayAnchor push is a no-op and I have no objection.
`Object.assign` ignores null, so every hunk is inert exactly where adoptAnchor already
owns the job. With the ruling at 1.00 these hunks change ZERO shipped numbers —
`factoryExtras('post')` returns `{ratingHint: 1}`, which is what `DEFAULT_RATING_HINT`
was already saying. The patch exists for the NEXT asset, not this one.
**4 · THE PIN — RULE 4, the third of the family** (no silent anchors, no unpriced
collateral, and now no baked hint that never arrives), in e.test.js, compared against
the MANIFEST and never against a literal — a pin that hard-coded 1.0 would have been
green through the entire bug:
· **RULE 4a** — manifest === GLBs, both directions: every source the manifest cites
exists and carries the same bake; every rated, non-denying anchor node in the palette
is accounted for as unambiguous or ambiguous. Vacuity-guarded (an empty manifest is a
fail, not a pass).
· **RULE 4b** — runtime === manifest: loads all three shipped sites, builds the real
worlds, and for every JSON-declared node-less anchor of a factory-rated type asserts
`anchor.ratingHint === manifest.rating_hint` and collateral agreement; refuses a
node-less anchor of an ambiguous type outright. Vacuity-guarded (backyard_01's five
posts guarantee it checks something; lose them all and it goes red).
· **Mutation check, the one the gate demanded:** set the manifest's post entry to 0.90
— the shape of this exact bug — and BOTH pins went red in the same run:
RULE 4a: `sail_post/top_anchor bakes rating_hint 1 but the manifest says 0.9`.
RULE 4b: `backyard_01/p1 plays at ratingHint 1; the factory baked 0.9 — the baked
hint never arrived`. That second message is word-for-word the sentence nobody could
say for fourteen sprints. Restored (hash-verified against the factory's own output),
green: 412/0/0.
**5 · THE RULING CONSTRAINT IS SATISFIED BY CONSTRUCTION.** The re-bake (this branch)
and the mechanism's world.js half (the patch above) land in the SAME integration —
there is no commit anywhere at which a post reads 0.90, because the re-baked GLB says
1.00, the manifest says 1.00, and until the patch is applied `?? 1` says 1.00 too.
B: your gate-1.3 re-measure should find ZERO deltas — that's the cheap verification the
ruling promised you, and if you find a nonzero one, the mechanism is the suspect, not
the tuning.
**6 · THE S14 OFFER, kept — what the next yard's palette needs, read out of D's
cold-authoring entry. Proposals only; A rules, numbers wear their reasoning:**
· **An honest middle rung between 0.45 and 1.00.** D authored site_03 by reading the
palette and the ladder jumps from the swing frame (0.45, on grass) straight to the
concreted post (1.00). A `pergola_01` at **rating_hint 0.65, collateral "pergola"
~$120** fills it: bolted to a deck, real bolts, no footing — better than a welded
frame on grass, worse than 300 mm of concrete, and the number argues exactly that.
One type, one rating, so it stays manifest-unambiguous and node-less-safe.
· **New types should ship ONE rating per type.** The manifest just made type-level
ambiguity a visible property: `tree` is ambiguous (the ladder IS the feature — six
rungs from 1.0 to 0.4, resolved per-node), and that's correct FOR TREES because tree
anchors always name nodes. A structural type that varies per-node forces every future
site to know node names; a type with one honest number works declared bare. Cheap
rule, real freedom preserved: it's a default, not a law — but breaking it should
cost a sentence of reasoning in the factory comment, like this one.
Pushed to origin lane/e: the factory re-bake + manifest machinery, the regenerated
GLB + report + gen module, ratings.js, the two pins, and this entry.
[C] 2026-07-20 — 🌩 **GATE 2.2 LANDED FROM MY SIDE — B, your proposal is TAKEN with one counter,
and the store is live on lane/c waiting for your three lines.** Read your 2026-07-20 seam
proposal back with diffs: I had landed a store the same day you posted (parallel lanes,
same conclusion — which is itself the argument for the store), so the convergence is
yours-except-where-measured, exactly like our 2.3 pin:
· **TAKEN, all four:** your filename (`web/world/js/editor_storm.js`), your global
(`globalThis.EDITOR_STORM`), your `source` tag (a debug string in the payload —
'wind-panel', 'wind-panel:clamp', 'wind-panel:worst' are mine), and **your default,
`storm_03_southerly`** — your authoring reasons beat my "open on the pin" (the pin is
still pinned in c.test.js; it never needed to be the default). Default second is 40 —
the S14 receipt's align-100% moment, so the page opens on a funnel that is firing.
· **THE COUNTER: the store carries `time` as well, and the API is symmetric —**
`SEL.storm`/`SEL.setStorm(key, source?)`, `SEL.time`/`SEL.setTime(t, source?)`,
`SEL.onChange(fn)` → unsubscribe, `stormSel()` → the singleton, `STORM_KEYS` (ONE list;
editor.wind.js re-exports the same array). The gate's own text says storm+TIME; your
panel ignores the half it doesn't use. Two contract points you'll want: **no-op writes
are silent** (return false, no emit — it's what lets my clamp write from inside my own
change handler without ringing, and it's mutation-checked), and **garbage keys throw**
(your setHardware lesson; also mutation-checked). Listener errors are contained.
· **File ownership: mine, only because it is already landed, asserted and mutation-checked
on lane/c while you are inside gate 2.1** — contest it at integration if you want it,
the API won't move either way.
· **Your three lines** (replace the private dropdown state in editor_score.js):
`import { stormSel } from './editor_storm.js';` · `sel.value = stormSel().storm;` +
`sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));` ·
`stormSel().onChange(({ storm }) => { sel.value = storm; });` — and drop your STORMS
list for `STORM_KEYS`. (If you'd rather your panel lose its select entirely and just
print the selection, that's also fine by me — the store doesn't care how many views it
has, only that they're views.)
· **My half is wired and verified live:** the wind panel's select and scrubber are VIEWS
of the store (private `stormKey`/`time` deleted); driving `EDITOR_STORM` from the
console moves my panel, over-long seconds clamp back through the store (setTime(200) →
90, no ring), and the overlay rebuilds per change. Receipt run: store southerly→console
setStorm→select follows; setTime(85.5)→readout follows.
**MY OWN-CALL POLISH: `JUMP TO WORST SECOND`.** D's cold-authoring session answered "when
does this yard scream?" by scrubbing and watching. Now it's a scan: `worstSecond(wind)`
argmax of `speedAt` over a coarse grid × the whole storm at the scrubber's own 0.5 s step,
venturi throats passed as exact probes so a coarse grid can't straddle the one point most
likely to win. Pure (dt,t), deterministic, and it writes the SHARED second through the
store like every other control. site_02 × wildnight: **51.11 m/s @ 60.5 s at (-7.5, 0)**
the funnel plateau, found in one click. The readout clears on any site change (your stale-
score instinct, borrowed). Storm-grade lighting preview I considered and SKIPPED: it drags
skyfx onto A's deliberately-calm page for a picture the FIELD card already numbers.
**Selftest 413/0/0** (410 baseline + 3: store contract, singleton/one-list, worst-second).
Seven mutations, each red-then-green with its own message: no-op guard off → "no-op fired",
validation off → "storm_99_nope accepted", singleton doubled → "two stores", comparator
flipped → "vacuous scan", probes dropped → "throat probe should win", default drifted →
"off the converged pair", source dropped → "payload should carry the writer's tag". The
DOM half of the panel wiring can't run in c.test and is verified live instead — said
plainly, per the disclosure standard.
[C] 2026-07-20 — 📐 **GATE 1.3 SECOND HARNESS — THE STORM SIDE ALSO READS ZERO DELTAS.** B
measured zero through the cloth (your table, one entry up); here is the same verdict
through the storm, independent by construction. `tools/storm_envelope/envelope.html`,
week mode, both shipped yards × all five nights, dressed anchors, against my own SPRINT13
tables (THREADS 2026-07-17, the gate-2.4 entry):
· **site_02 × early buster: byte-identical on every anchor row** — all eleven of
cb2/cp1/cb1/cp2/throat/q1/tr1b/tr1/q2/q4/q3, every column (peak m/s, tPeak, peak Pa,
Pa/hint, dose) at the recorded precision. cb2 still 30.39 @ 49.0 → 2886 Pa/hint; throat
still 33.51 @ 48.8; q1 still 538.
· **backyard_01, all four nights: identical** — h1-h3 Pa/hint gentle 266 · southerly
946-1037 · wildnight 2093-2505 · icenight 1844-1933; best honest post 94/340/810/739;
branch ladder still t1b/t2b 0.88, t1c 0.76; t1 southerly still 15.45 m/s (the
branch-shadowing number).
· **Honest posts read hint 1.00 in the dressed anchors** — which under the gate-1.1
ruling is now canon agreeing with itself, not a bug being measured.
**Tree measured: lane/c @ my gate-2.2 commit on top of main c182fc1.** E's re-bake had
NOT landed on origin/lane/e at measure time (same situation as B's run) — under the 1.00
ruling the runtime value doesn't move, so zero deltas were expected regardless of which
side of the re-bake the tree sits on, and zero deltas is what both harnesses measured.
Nothing to flag. If the integrator wants a THIRD reading post-E-merge it is one page
load: envelope.html, week mode, compare against this entry.
[B] 2026-07-20 — ✅ **GATE 1.3 — THE ZERO-DELTA VERIFICATION: ZERO DELTAS, measured, not assumed.**
Tree measured: **main @ c182fc1** (lane/b fast-forwarded to it; E's re-bake/manifest half had
NOT landed on lane/e at measure time — under the 1.00 ruling the runtime value doesn't move,
so this re-measure doesn't block on E; if their landing somehow moves a number, their own
gate-1.2 assert is the tripwire). audit.html on all three yards, plus the editor SCORE IT
card on site_02 and site_03, against the S14 pinned numbers:
| yard / storm | S14 pinned | re-measured @ c182fc1 | Δ |
|---|---|---|---|
| backyard_01 / wildnight | 16 cands · 3 clean · 2 marginal · bare 35.7 TATTERED · best p1,p2,p3,p4 60.5 · sep MEETS held 63.8 | identical, every number | **0** |
| site_02 / earlybuster | 66 / 20 / 1 · bare 82.6 FULL · best tr1,tr1b,q1,q4 91.3 · cheapest honest tr1,tr1b,q1,q3 $40 (+$15=$55) · 31 m² · 86.4 FULL · +3.8 HP · worst flag q4 0% · collateral $180 | identical, every number | **0** |
| site_03_swing_lawn / southerly | 81 / 11 / 16 · cheapest honest h3,t2,t2b,p1 $65 (holds $55) · 22 m² · 85.3 FULL · bare 83.7 → best clean h3,t1c,t2,p1 90.0 (+6.3) · best-reading marginal t1c,t2,t2b,s1_f2 91.8 · flags t2b 0%, t1/t1b/p1/s1_f1 1% · collateral $230 | identical, every number (D's card, reproduced on my machine) | **0** |
Selftest on the same tree: **410/0/0** (the integrator-verified baseline). Two things this
table quietly proves beyond the ruling: (1) the fresh-eyes review's sim fixes (divergence
break on the shipped path, pond cap post-flow) are empirically no-ops on every shipped score
path — the review argued they only bite on non-finite corners / post-rain stacking, and the
numbers agree; (2) D's site_03 card numbers reproduce exactly cross-machine — only the WALL
TIME differs (their 82.5 s was 139.9 s on my measuring browser), which is gate 2.1's whole
argument. Editor cards for site_02 (71.9 s) and site_03 (139.9 s) are captured in full as
the gate-2.1 pins: a faster score that moves ANY of these numbers is a regression.
[B] 2026-07-20 — 🔌 **GATE 2.2 PROPOSAL FOR C — one storm picker, posted early so you can veto
while I'm inside gate 2.1.** D scored the wildnight while your panel showed the southerly:
two private `stormKey`s, independently defaulted (my STORMS leads wildnight, your
DEFAULT_STORM is southerly). Proposal, shaped like our 2.3 convergence — counter and I match:
· **One tiny shared module, `web/world/js/editor_storm.js`, Lane B owns the file**; it owns
the state and the ONE storm list. API: `stormSel.key` (getter), `stormSel.set(key, source)`
(no-op on same key, throws on a storm not in the list — setHardware's lesson),
`stormSel.onChange(fn)` → unsubscribe, `stormSel.STORM_KEYS`. Also exposed as
`globalThis.EDITOR_STORM` for consoles/selftests. It deliberately does NOT ride A's
`EDITOR.on` — emit is private to editor.js, and asking A to widen the seam for this is
more contract than one shared dropdown needs.
· **The shared default: `storm_03_southerly` — YOUR default, not mine.** Reasons, not
courtesy: your panel is live the moment the page opens and needs a storm that shows
something (your own comment — a funnel authored under the gentle storm looks like it does
nothing); SCORE IT is an explicit click behind a visible dropdown; site_03 — the yard most
likely to be iterated next — was authored and played on the southerly; and my separation
judging never depended on the dropdown anyway (the block names its own storm).
· **My half lands on lane/b now**: editor_score.js's dropdown reads/writes `stormSel`, so
the moment your panel subscribes, changing the storm in either place changes it in both.
Until your half lands, behaviour is unchanged for you (my module holds the state; your
panel keeps its private key). **Your half**: drop `STORM_KEYS`/`DEFAULT_STORM`/`stormKey`
from editor.wind.js, import `stormSel`, subscribe → `ensureStorm + refreshWind + render`,
and write `stormSel.set` from your select. Two selects, one state, zero A edits.
[B] 2026-07-20 — ✅ **GATE 2.1 + 2.2 LANDED — THE SCORE PAINTS, YIELDS, AND MOVED NOTHING; ONE
STORM PICKER, CONVERGED ONTO C'S STORE.** Selftest **413/0/0** (410 baseline + 3, all three
mutation-checked red-then-green in one combined run: an impure chunk boundary + a swallowed
tick lit all three with their intended diagnostics). Pushed on lane/b.
**2.1 — WHAT CHANGED AND WHAT PROVABLY DIDN'T.** The sweep's per-candidate flight is
extracted (`findCandidates` / `priceCandidate` / `judgeSweep`, one copy of the math — sync
`auditSweep` and the new `auditSweepAsync` are the same functions in a different loop), and
`scoreSite` now yields to the event loop between flights with `onProgress` ticks (phases
sweep/fly/separation). The editor card and audit.html both render the tick:
`sweeping — flight 43/81 quads · 61.2 s` / `flying the garden — line 4/13 · h3,t1c,p1,s1_f1`.
D's priority order taken literally: (a) paint, (b) responsiveness; on (c) speed I looked at
the offered cull and DECLINED it — the coarse-rank-then-cull idea changes card numbers by
construction (clean/marginal COUNTS and the margin-flag table are computed over every
priced row, and those counts are on the card), so a cull is a design change needing its own
ruling, not a speedup. `FLY_CAP` and `yieldEvery` are the knobs that exist; D, say the word.
**THE PIN, REPRODUCED TO THE DIGIT.** Captured both editor cards in full BEFORE the
refactor, reproduced after — every number identical on site_02/earlybuster (66/20/1,
tr1,tr1b,q1,q3 $40, 86.4 FULL +3.8, bare 82.6, best 91.3 +8.7, hail/rain 5.1/3.5, all ten
margin flags to the kN, carport $180) and site_03/southerly (81/11/16, h3,t2,t2b,p1 $65
holds $55, 85.3 +1.6, bare 83.7, best 90.0 +6.3, marginal-best 91.8, all eleven flags,
$230). audit.html backyard_01/wildnight re-run through the new driver: 16/3/2, bare 35.7,
best 60.5, sep MEETS held 63.8 — identical, separation path included. The chunk-boundary
mutation is also an ASSERT now: sync vs yieldEvery 1 vs yieldEvery 3 must be byte-identical
JSON (and went red when I deliberately leaked the boundary into a row).
**TIMINGS, honestly, because the wall clock on this laptop is lying to everyone today.**
First-run wall clocks drifted 71.9 → 238.8 s on the SAME yard across two hours — five lane
agents are hammering this machine in parallel, so cross-run comparisons are mush. So the
before/after is a back-to-back controlled pair, same page, same prebuilt world,
site_02/earlybuster: **blocking (yieldEvery=∞) 139.2 s, page frozen** (my mid-run probes
timed out — the old experience, reproduced on demand) vs **yielding (yieldEvery=1) 187.0 s,
page live the whole way** (probed it repeatedly mid-run; the progress line answered every
time). The +34% is NOT the yield — an idle yield measures 0.030.2 ms (bench in-page) — it
is the editor's rAF frame getting to run between chunks on a contended CPU, i.e. the page
doing its job. On D's machine (which renders this editor at interactive rates while
authoring) the per-yield cost is one frame, single-digit ms. **D: gate 2.3 is yours — judge
the loop in anger, and if the tick cadence or the tradeoff is wrong for authoring, both
knobs are one line.**
⚠️ Two field notes with teeth, for whoever touches this next:
· **`yieldToEventLoop` is a MessageChannel task, NOT setTimeout(0), and that is
load-bearing.** Chrome's intensive timer throttling clamps chained timers in
hidden/occluded tabs to ONE WAKE PER MINUTE — measured: the first yielding run crawled
12→13 of 66 flights across 60 s in an occluded pane. The selftest header's rule ("stays
honest in a background tab") applies to the score too; message tasks aren't throttled.
· The editor page's score now defaults to the SHARED storm (southerly), not wildnight —
that is gate 2.2 working as specced, saying it here so nobody reads a southerly card
and remembers a wildnight default.
**2.2 — CLOSED, C's store, my three lines.** C converged onto my proposal harder than I
proposed it (their entry, one up) and countered with storm+TIME symmetric API — counter
TAKEN, same as 2.3: I deleted my draft `editor_storm.js` and adopted C's file BYTE-IDENTICAL
onto lane/b (`git checkout origin/lane/c -- web/world/js/editor_storm.js`, so integration
merges clean; the file is C's, uncontested). editor_score.js is a view of the store now:
`sel.value = stormSel().storm`, writes via `setStorm(key,'score')`, subscribes via
`onChange`. Verified live on my tree: default southerly@40 ✓ · console `setStorm` moves my
select ✓ · my select moves the store ✓ · no-op returns false ✓ · garbage throws ✓. (C's
wind-panel half lives on lane/c; both halves meet at integration, where D's
scored-one-viewed-another becomes unrepresentable.)
**GATE 1.3 CODA:** C's storm-side envelope run (their entry, one up) reads the same zero my
cloth-side table read — two independent harnesses, both zero, both measured pre-E-landing
under a ruling where the runtime value cannot move. **D: gate 1.4 can be recorded as
nothing-to-feel** unless E's landing surprises everyone, and E's own assert is the tripwire
for that.
**NOT TAKEN (declared, not dropped):** A's gate-3.4 offer (a "copy measured separation
block" affordance on the card). It is NOT a mechanical copy — the block's thresholds
(heldMustExceed / bareMustLoseBelow) are design margins around measured numbers, and
picking them is a ruling, not a clipboard. Still worth building; it needs its own slot and
A's thresholds-policy sentence first. **SPRINT16 note:** the chunking keeps
`priceCandidate`/`flyGarden` as the atomic units — the node-position heal after a
divergence break lives inside sail.js, BELOW the chunk seam, so TEETH's sanitation gate
does not fight this refactor; membrane-balance flights ride the same driver unchanged.
[D] 2026-07-20 — 🎛 **GATE 2.3 VERDICT — THE EDITOR IS ITERATIVE NOW. My S14 sentence was "I scored
once and shipped that run rather than tuning"; this session I scored my own yard THREE times
from three garden-bed positions, predicted each card before it landed, was wrong once, and
kept the shipped design because the numbers said to. That is authoring WITH the tool. Judged
on the scratch merge of b+c+e (selftest 418/0/0 — the exact 410 + B 3 + C 3 + E 2 sum), in
the editor, on site_03_swing_lawn, southerly, funnel on.**
**THE LOOP, in anger — the bed study S14-me explicitly couldn't afford** ("I would have
moved the bed and re-scored if it had been 8 s"):
· **(0, 0) shipped:** 81 quads · 11 clean · 16 marginal · cheapest honest h3,t2,t2b,p1 $65
(holds $55) · 85.3 FULL +1.6 · bare 83.7 · best clean 90.0 +6.3 · marginal-best 91.8
flagged as the trap · $230 collateral. **Every number identical to my S14 card** — see the
gate-1.4 entry below for what that also proves. 231.9 s wall.
· **(-4, 0), into the venturi's mouth:** 34 quads (the tick told me the candidate space had
HALVED at flight 3/34, three minutes before the card landed) · 9 clean · cheapest honest
becomes h2,t2,s1_f1,s1_f2 $65 holds $50 — **the swing set's 0.45 steel is now load-bearing
on the honest line. The thesis inverts: the trap becomes the recommended play,** carrying
its own $140 collateral. 122.4 s.
· **(3, 0), under the jacaranda:** 83 quads · 18 clean · cheapest honest is the all-tree line
t1b,t1c,t2,t2b at **$40**, and the best-reading-marginal trap warning VANISHES off the card.
I predicted "meaner"; the card said SOFTER — height stops being bad when the bed moves in
under it. Wrong in one run instead of wrong in a shipped yard.
· **Bare bed reads 83.7 at all three positions** — bed placement tunes the RIG question, not
the bare baseline. Worth knowing before SPRINT16 wires this yard as a night.
· Reverted to (0, 0) through the panel; **EXPORT is byte-identical to the shipped JSON**
(normalized compare, 5398 = 5398) — a three-run tuning session leaves no residue.
**B's three bets, judged:**
· **The tick matters more than raw speed — CONFIRMED, and it's better than the bet.** The
phase+flight+line tick ("sweeping — flight 43/81 · 61.2 s", "flying the garden — line
11/13 · t1,p1,s1_f1,s1_f2") isn't just proof of life: **the quads-in-band count is the
first design number, and it arrives at flight 1.** Not-knowing-if-it-died is dead.
· **Responsiveness — CONFIRMED with receipts.** Mid-run at flight 19/81 I scrubbed the storm
to 60 s and the venturi readout recomputed live (15.5 m/s · align 31%); mid-run at ~flight
11/83 I fired JUMP TO WORST SECOND and the scan returned in **4.4 ms**. The page authors
while it scores. Wall times on this machine (running the whole sprint's agents): 231.9 /
122.4 / 241.3 s — still batch-SIZED, no longer batch-SHAPED, and the honest unit of
authoring cost is now "start a score, keep working" rather than "watch a dead page."
Tick cadence is right; leave both knobs where they are.
· **The declined cull — RIGHT CALL.** The clean/marginal counts and the flag table carried
half this verdict's design information; a coarse-rank cull would have changed exactly the
numbers I tuned against.
**GATE 2.2, the picker — the trap is unrepresentable, verified both directions on the real
page:** both selects open on southerly@40 (C's converged default); WIND's select moved →
SCORE's followed + store agreed; SCORE's select back → WIND followed. My
scored-the-wildnight-while-viewing-the-southerly cannot happen; there is nothing left to
hold two opinions.
**C's JUMP TO WORST SECOND — EARNS ITS BUTTON, twice over.** One click: **27.84 m/s @ 45.5 s
at (-4.5, -6.4)** — the house-shed gap, found instantly, deterministic across two invocations,
and it wrote the shared second through the store. In S14 I found this by scrubbing and
watching. And the receipt it didn't promise: **in the gate-1.4 replay below, h3 let go
between simT 47 and the change's hail — the scanner's second is when the yard actually
screamed in play.** The readout beats the FIELD card's own fastest (27.71 coarse grid vs
27.84 exact throat probe), which is C's probe design doing exactly what their entry said.
**A's gate-3 edges, exercised in passing:** Enter committed my bed moves on the SELECTED
panel — typed -4, Enter, no blur, site data moved (measured after ruling out my own
harness's fake Enter, which arrives as key:"" — same tooling-artifact family as my S14
wheel-scroll false alarm, filed here so the next agent doesn't file it as A's bug).
**VERDICT: gate 2.3 CLOSED — the loop changed how I work, and this entry's bed study is the
proof it couldn't have produced in S14.** What I'd still want, in order: (a) B's declined-cull
stance made canon on the card (a sentence saying counts are census, not sample, so nobody
"optimises" them away); (b) the separation-block copy affordance B parked, once A writes the
thresholds-policy sentence. Neither blocks anything.
[D] 2026-07-20 — 🌅 **GATE 1.4 CLOSED — I PLAYED THE HONEST LINE ON THE SCRATCH MERGE AND NOTHING
IS DIFFERENT, to the decimal. Two harnesses said zero; the player's hands agree, and that
sentence is the receipt the gate asked for.**
**The run:** night 2 pointed at the swing lawn exactly as my S14 play scratch did (week.js
NIGHTS[1] edit + a boot page using main.js's own `boot({site, bank, splash:false})` door —
both scratch-only, reverted before this entry landed, lane/d ships neither). Job sheet read
JOB 1042 · the Delaneys · NIGHT 2 OF 5 · SOUTHERLY BUSTER · $122 the night done right — the
S14 letterhead verbatim. Rigged the cheapest honest line through the real prep UI, clicks on
rings, **h3,t2,t2b,p1, $15 shackles across, $60 spent of $80** — the S14 "$60 at the same
money" flight, re-flown with hands.
· **p1 committed at eff 3,200 = shackle 3,200 × ratingHint 1.00** — the gate-1.1 ruling
alive in the real commit chain on a tree carrying E's re-bake. A leaked 0.90 would have
read 2,880 in the panel; it read 3,200.
· **The shackle at h3 let go** mid-southerly (broken by simT 47.4, the change's hail window,
the second C's scanner names) — the EXACT corner my S14 entry said this line loses at $60.
· **Garden finished 85.18 → invoice "garden 85%"** against S14's played **85.2 FULL**. Same
number, same corner, same bill: **the gutter $90**, clean-site struck, gear recovered
+$23, **amount due $32, bank $80 → $48**, "THE GARDEN MADE IT. The shackle at H3 let go
and the rest carried it."
· The editor-side pin agrees: my baseline gate-2.3 card reproduced the S14 site_03 card to
the digit on the SAME scratch — which is the **third, post-E-merge reading C offered the
integrator**, from the third harness (the authoring path), and it also read zero. E's
world.js hunks were NOT applied (integrator's patch, as filed); under the ruling `?? 1`
and `factoryExtras` say the same word, and the play just measured that they do.
· Selftest on the scratch before judging: **418/0/0** — the exact per-lane sum
(410 + B 3 + C 3 + E 2). Scratch branch deleted after this entry; lane/d carries THREADS
only.
**Harness disclosure, per my own S14 standard:** every rig action, hardware cycle, commit
(Enter) and the score runs went through the real UI. Three tool-driven exceptions, declared:
camera orbit via `cameraRig.yaw` (the RMB-drag equivalent; my pane can't right-drag), one
card scrolled into view before its real click, and — the one with teeth — **the storm was
advanced with the game's own exposed fixed-dt `step(FIXED_DT)` after measuring the pane's
rAF throttle at 0.6 SIM-SECONDS PER 30 WALL-SECONDS** (occluded-surface throttling, the
same disease B's MessageChannel note documents; the game loop is rAF-driven by design and
honest about it). Fixed-dt is the sim's native gait — same chain, same determinism, and the
85.18 landing on S14's 85.2 is the cross-check that it measured the same game.
**🔩 E's pergola_01 (0.65, ~$120), the playtester's sentence gate 6 asked for:** yes, build
it — authoring site_03 the ladder jumped straight from 0.45-on-grass to 1.00-in-concrete
with nothing between, and tonight's replay shows exactly where the missing rung bites: the
honest line's weakest slot is a 0.35 fascia that breaks at affordable money, so a 0.65
bolted-to-deck option would turn "fascia or nothing" into a real spend-vs-risk decision —
the number argues its own case and I'll build against it gladly when SPRINT16 wires the
swing lawn.
[I] 2026-07-19 — **SPRINT 15 INTEGRATION (main).** All four lanes merged; E's world.js patch applied
verbatim at merge exactly as filed (four hunks, the leadFor precedent — comment in world.js says
so); selftest **418/0/0** on the merged tree, the exact sum D predicted on their scratch (410 +
B 3 + C 3 + E 2), verified in the browser. Game boots clean; posts adopt at ratingHint 1.00
through E's manifest, live in the real chain (D measured p1 committing at eff 3,200 in play —
a 0.90 world would have read 2,880).
**The sprint closed every gate it opened:**
· Gate 1 — ruled 1.00 (shipped value canon), re-baked, mechanism landed (factory manifest →
runtime, ambiguous types refuse to guess, unrated-undenied nodes fail the BUILD), pinned
against the manifest never a literal, and verified zero-delta from THREE directions (B cloth,
C storm, D played). The expensive-sounding gate became cheap because the ruling came first —
sequencing is a tool.
· Gate 2 — the score is iterative and moved nothing (every card byte-identical; MessageChannel
not setTimeout, or Chrome's throttling clamps an occluded run to one flight per minute); ONE
storm picker via a shared store B proposed and C landed converged; D's acceptance verdict is
the receipt: a three-position bed study with predictions before each card, one prediction
wrong IN A RUN instead of in a shipped yard. C's worst-second button predicted the second h3
actually broke in play. "Batch, not iterative" is dead.
· Gate 3 — landed last integration (honest edges), stands.
· Gate 4 — branch RULED by John (all three, sequenced: teeth → sites/clients → heatwaves;
ROADMAP.md is the growth canon). The play half stands open as ever.
Carried forward: B declined the coarse-rank cull as a design change needing a ruling (correct —
it changes card numbers by construction); E's pergola_01 proposal has D's playtester endorsement
("the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65 bolted-to-deck mid-rung
turns fascia-or-nothing into a real decision") — TEETH's palette call. D filed their own
harness's fake-Enter artifact so nobody blames A's gate-3.1 work.
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
real night.**

View File

@ -14,6 +14,7 @@
"ref_capsule",
"ref_capsule_mesh"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -36,6 +37,23 @@
"tree_gum_01",
"trunk"
],
"anchors": [
{
"node": "branch_anchor_01",
"anchor_type": "tree",
"rating_hint": 1.0
},
{
"node": "branch_anchor_02",
"anchor_type": "tree",
"rating_hint": 0.88
},
{
"node": "branch_anchor_03",
"anchor_type": "tree",
"rating_hint": 0.76
}
],
"status": "PASS",
"problems": []
},
@ -56,6 +74,18 @@
"tree_gum_02",
"trunk"
],
"anchors": [
{
"node": "branch_anchor_01",
"anchor_type": "tree",
"rating_hint": 1.0
},
{
"node": "branch_anchor_02",
"anchor_type": "tree",
"rating_hint": 0.88
}
],
"status": "PASS",
"problems": []
},
@ -79,6 +109,23 @@
"tree_jacaranda_01",
"trunk"
],
"anchors": [
{
"node": "branch_anchor_01",
"anchor_type": "tree",
"rating_hint": 0.95
},
{
"node": "branch_anchor_02",
"anchor_type": "tree",
"rating_hint": 0.52
},
{
"node": "branch_anchor_03",
"anchor_type": "tree",
"rating_hint": 0.4
}
],
"status": "PASS",
"problems": []
},
@ -94,6 +141,7 @@
"fence_post",
"post"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -110,6 +158,7 @@
"palings",
"rails"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -128,6 +177,7 @@
"hinge_axis",
"hinges"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -153,6 +203,30 @@
"window_glow",
"window_light_anchor"
],
"anchors": [
{
"node": "fascia_anchor_01",
"anchor_type": "house",
"rating_hint": 0.35,
"collateral": "gutter"
},
{
"node": "fascia_anchor_02",
"anchor_type": "house",
"rating_hint": 0.35,
"collateral": "gutter"
},
{
"node": "fascia_anchor_03",
"anchor_type": "house",
"rating_hint": 0.35,
"collateral": "gutter"
},
{
"node": "window_light_anchor",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
@ -178,6 +252,12 @@
"window_glow",
"window_light_anchor"
],
"anchors": [
{
"node": "window_light_anchor",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
@ -200,6 +280,32 @@
"posts",
"roof"
],
"anchors": [
{
"node": "beam_anchor_01",
"anchor_type": "carport",
"rating_hint": 0.22,
"collateral": "carport"
},
{
"node": "beam_anchor_02",
"anchor_type": "carport",
"rating_hint": 0.22,
"collateral": "carport"
},
{
"node": "post_anchor_01",
"anchor_type": "carport_post",
"rating_hint": 0.3,
"collateral": "carport"
},
{
"node": "post_anchor_02",
"anchor_type": "carport_post",
"rating_hint": 0.3,
"collateral": "carport"
}
],
"status": "PASS",
"problems": []
},
@ -218,6 +324,7 @@
"posts",
"roof_down"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -237,6 +344,24 @@
"swing_set_01",
"swings"
],
"anchors": [
{
"node": "crossbar",
"tie_off": false
},
{
"node": "frame_anchor_01",
"anchor_type": "swing_frame",
"rating_hint": 0.45,
"collateral": "swing_set"
},
{
"node": "frame_anchor_02",
"anchor_type": "swing_frame",
"rating_hint": 0.45,
"collateral": "swing_set"
}
],
"status": "PASS",
"problems": []
},
@ -254,6 +379,12 @@
"swing_set_01_wrecked",
"swings"
],
"anchors": [
{
"node": "crossbar",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
@ -272,6 +403,12 @@
"shed_01",
"shell"
],
"anchors": [
{
"node": "door_anchor",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
@ -289,6 +426,12 @@
"table_frame",
"table_top"
],
"anchors": [
{
"node": "pickup_anchor",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
@ -308,6 +451,7 @@
"plants_tattered",
"soil"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -327,6 +471,13 @@
"sail_post",
"top_anchor"
],
"anchors": [
{
"node": "top_anchor",
"anchor_type": "post",
"rating_hint": 1.0
}
],
"status": "PASS",
"problems": []
},
@ -344,6 +495,7 @@
"ladder_base",
"ladder_top"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -360,6 +512,7 @@
"pin",
"shackle"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -376,6 +529,7 @@
"carabiner",
"gate"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -393,6 +547,7 @@
"eye_b",
"turnbuckle"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -411,6 +566,7 @@
"rim",
"tramp_01"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -429,6 +585,7 @@
"wheelie_bin_01",
"wheels"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -446,6 +603,7 @@
"mast",
"washing_line_01"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -461,6 +619,7 @@
"garden_gnome_01",
"gnome"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -479,6 +638,7 @@
"wheel_front",
"wheel_rear"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -497,6 +657,7 @@
"shards",
"stump"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -512,6 +673,7 @@
"hail_stone_01",
"stone"
],
"anchors": [],
"status": "PASS",
"problems": []
},
@ -531,6 +693,12 @@
"head",
"poke_tip"
],
"anchors": [
{
"node": "grip_anchor",
"tie_off": false
}
],
"status": "PASS",
"problems": []
},
@ -548,6 +716,7 @@
"palings",
"rails"
],
"anchors": [],
"status": "PASS",
"problems": []
}

View File

@ -70,6 +70,16 @@ DEBRIS_DIR = os.path.join(MODELS_DIR, "debris")
TEXTURES_DIR = os.path.join(MODELS_DIR, "textures")
CONTACT_SHEET = os.path.join(SCRIPT_DIR, "contact_sheet.png")
REPORT_JSON = os.path.join(SCRIPT_DIR, "asset_report.json")
# SPRINT15 gate 1.2 — the ratings manifest the RUNTIME reads. A baked
# rating_hint used to reach the sim only through adoptAnchor, which only runs
# for anchors that name a GLB `node` — every plain-JSON post defaulted to
# `?? 1` no matter what this factory baked, and factory and sim silently
# disagreed for fourteen sprints. This module is the missing wire: per
# anchor_type, the factory's baked extras, generated HERE so it cannot be
# hand-edited into a second source of truth. world.js resolves a node-less
# JSON anchor's type through it; e.test.js pins runtime === manifest === GLB.
RATINGS_GEN_JS = os.path.join(REPO_ROOT, "web", "world", "js",
"anchor_ratings.gen.js")
# The 3D-STORE library on this box. PLAN3D §2 lists ~/Documents/3D-STORE (that
# path is from the M1 Ultra); on the M3 Ultra the library lives here. Checked in
@ -1684,7 +1694,16 @@ def build_sail_post(name):
e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2)
e["anchor_type"] = "post"
e["rating_hint"] = 0.9
# SPRINT15 gate 1.1 RULING (THREADS [A] 2026-07-18): the honest post is
# 1.00 — the shipped value becomes canon. The 0.90 baked here from Sprint 1
# never reached the sim: every shipped post is a plain JSON entry with no
# `node`, so adoptAnchor never ran and `?? 1` won. Every measured number in
# the repo (separation pins, winnable-lines tables, night 5's whisker) was
# measured at effective 1.00; 0.90 was design intent that never shipped.
# A weaker honest post later is a DESIGN CHANGE proposed with evidence,
# wearing its own name — not a bugfix. Do not lower this without
# re-opening that ruling.
e["rating_hint"] = 1.0
above.append(e)
for o in above:
parent_keep_transform(o, rake)
@ -3137,6 +3156,104 @@ def add_label(cam, text):
return t
def extract_anchor_meta(objs):
"""The anchor-family custom props (`anchor_type`, `rating_hint`, `tie_off`,
`collateral`), read off the RE-IMPORTED GLB rather than the build graph
so the report describes what actually shipped through the exporter, not
what this script intended. Same provenance rule as the dims/nodes checks.
"""
out = []
for o in objs:
keys = set(o.keys())
if "anchor_type" not in keys and "tie_off" not in keys:
continue
m = {"node": o.name}
if "anchor_type" in keys:
m["anchor_type"] = str(o["anchor_type"])
if "rating_hint" in keys:
m["rating_hint"] = round(float(o["rating_hint"]), 4)
if "tie_off" in keys:
m["tie_off"] = bool(o["tie_off"])
if "collateral" in keys:
m["collateral"] = str(o["collateral"])
out.append(m)
return sorted(out, key=lambda m: m["node"])
def build_ratings_manifest(report):
"""Collapse the per-asset anchor meta into per-TYPE ratings the runtime can
resolve. The contract:
· `tie_off: false` nodes are DENIALS, not anchors excluded.
· a type where every rated node in the palette agrees on
(rating_hint, collateral) is UNAMBIGUOUS and lands in `types` a
node-less JSON anchor of that type takes these extras at runtime.
· a type whose nodes disagree (tree: 1.0/0.88/0.76 down the trunk) is
AMBIGUOUS listed with every variant so the runtime can refuse to
guess. A node-less anchor of an ambiguous type is a site bug; e.test
pins that no shipped site declares one.
· a node that carries `anchor_type` but no `rating_hint` and no denial
violates RULE 1 (no silent anchors) and fails the build loudly here,
before the GLB can ship.
"""
per_type = {}
for a in report:
for m in a.get("anchors", []):
if m.get("tie_off") is False:
continue # explicit denial: not an anchor
ty = m.get("anchor_type")
if ty is None:
continue
if "rating_hint" not in m:
raise RuntimeError(
f"{a['name']}/{m['node']} carries anchor_type={ty!r} with "
f"no rating_hint and no tie_off denial — RULE 1 (no silent "
f"anchors). Rate it or not_a_tie_off() it.")
key = (m["rating_hint"], m.get("collateral"))
per_type.setdefault(ty, {}).setdefault(key, []).append(
f"{a['name']}/{m['node']}")
types, ambiguous = {}, {}
for ty in sorted(per_type):
variants = per_type[ty]
if len(variants) == 1:
(hint, coll), sources = next(iter(variants.items()))
entry = {"rating_hint": hint, "sources": sorted(sources)}
if coll is not None:
entry["collateral"] = coll
types[ty] = entry
else:
ambiguous[ty] = {"variants": [
dict(rating_hint=h, sources=sorted(srcs),
**({"collateral": c} if c is not None else {}))
for (h, c), srcs in sorted(variants.items(),
key=lambda kv: (-kv[0][0],
kv[0][1] or ""))
]}
return {"types": types, "ambiguous": ambiguous}
def write_ratings_module(manifest):
body = json.dumps(manifest, indent=2, sort_keys=True)
header = (
"// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit.\n"
"// Regenerate: blender -b -P tools/blender/build_yard_assets.py\n"
"//\n"
"// SPRINT15 gate 1.2 — the wire between the factory and the sim.\n"
"// adoptAnchor only carries a baked rating_hint to anchors that name a\n"
"// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted\n"
"// to `?? 1` regardless of the bake, and nobody could see the\n"
"// disagreement. This module is the factory's bakes, resolvable by\n"
"// anchor TYPE at runtime: `types` are unambiguous (every rated node\n"
"// of that type agrees), `ambiguous` lists the disagreements so the\n"
"// runtime can refuse to guess. ratings.js is the reader; e.test.js\n"
"// pins runtime === this module === the GLBs, so none of the three\n"
"// can drift from the others silently again.\n"
)
with open(RATINGS_GEN_JS, "w") as f:
f.write(header + "export const FACTORY_ANCHOR_RATINGS = "
+ "Object.freeze(" + body + ");\n")
def verify_all(only=None):
todo = [a for a in ASSETS if only is None or a["name"] in only]
print(f"\n--- VERIFY {len(todo)} GLBs (re-import + assert + render) ---\n")
@ -3202,8 +3319,8 @@ def verify_all(only=None):
for p in problems:
print(f" -> {p}")
report.append(dict(name=name, dims=dims, tris=tris,
nodes=sorted(names), status=status,
problems=problems))
nodes=sorted(names), anchors=extract_anchor_meta(objs),
status=status, problems=problems))
return report, failures, thumbs
@ -3293,6 +3410,16 @@ def main():
json.dump(dict(blender=bpy.app.version_string, assets=report,
debris=debris), f, indent=2)
print(f" report -> {REPORT_JSON}")
# The runtime manifest is only honest when built from the WHOLE
# palette — an --only run would collapse "every node of this type
# agrees" to a sample of one and could flip an ambiguous type to
# unambiguous. So partial runs leave it untouched, loudly.
if only is None:
write_ratings_module(build_ratings_manifest(report))
print(f" ratings -> {RATINGS_GEN_JS}")
else:
print(" ratings -> NOT rewritten (--only run is a partial "
"palette; run the full factory to regenerate)")
print("\n" + "=" * 72)
if failures:

View File

@ -91,7 +91,17 @@ async function run() {
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
: null;
const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
// scoreSite yields between flights now (SPRINT15 gate 2.1), so this page can
// say where it is instead of sitting on "loading…" for a minute.
const t0 = performance.now();
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, onProgress: (p) => {
const secs = ((performance.now() - t0) / 1000).toFixed(1);
el('sub').textContent = p.phase === 'sweep'
? `${siteName} / ${stormName} — sweeping: flight ${p.done}/${p.total} quads · ${secs} s`
: p.phase === 'fly'
? `${siteName} / ${stormName} — flying the garden: line ${p.done}/${p.total}${p.label ? ` (${p.label})` : ''} · ${secs} s`
: `${siteName} / ${stormName} — judging the separation target (${p.label}) · ${secs} s`;
} });
const {
dressed, cands, rows, verdict, winners, marginalWinners,
flown, skipped, bare, separation: sep, sepStormName,

View File

@ -53,7 +53,7 @@
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld } from '../../web/world/js/world.js';
import { AUDIT, auditSweep } from './sweep.js';
import { AUDIT, auditSweepAsync, yieldToEventLoop } from './sweep.js';
import { flyGarden, flySeparation } from './gardenfly.js';
/** How many affordable lines get flown. Flights are seconds each; the card is
@ -117,9 +117,19 @@ export async function buildScoringWorld(site) {
* the keys match, else reported unjudged
* @param {number} [o.flyCap]
* @param {object} [o.prebuilt] a buildScoringWorld() result to reuse
* @param {function} [o.onProgress] called ({ phase, done, total, label }) as
* work completes phases 'sweep' (per candidate flight),
* 'fly' (per garden flight, bare first), 'separation'.
* SPRINT15 gate 2.1: the 7582 s blocking run made the
* editor batch; the caller renders this so the page tells
* the truth about progress instead of looking crashed.
* @param {number} [o.yieldEvery] work units per event-loop yield (default
* 1). MUST NOT change any number the scorecard selftest
* perturbs it and demands identical results.
* @returns {Promise<object>} pure data no DOM, no strings-as-verdicts
*/
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null }) {
export async function scoreSite({ site, stormDef, stormName = null, sepStormDef = null, flyCap = FLY_CAP, prebuilt = null,
onProgress = null, yieldEvery = 1 }) {
const built = prebuilt ?? await buildScoringWorld(site);
const { world, anchors, bed, use, dressed, dressError } = built;
@ -155,16 +165,29 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
}
const { cands, rows, verdict, winners, marginalWinners } =
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
await auditSweepAsync({ anchors, bed, stormDef, siteDef: site, use, onProgress, yieldEvery });
// Garden flights: the bare-bed control first, then every line the budget can
// buy. The flight list is known up front so the progress tick has an honest
// denominator (bare + capped lines + the separation pair if pinned).
const toFly = [...winners, ...marginalWinners].slice(0, flyCap);
const flyTotal = 1 + toFly.length;
let flyDone = 0;
const tickFly = async (label) => {
flyDone += 1;
onProgress?.({ phase: 'fly', done: flyDone, total: flyTotal, label });
if (flyDone % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
};
// The bare bed — the control every garden number is read against.
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
await tickFly('bare bed');
// Fly every line the budget can buy. Marginal lines fly too, deliberately:
// they are the trap the margin rule exists to name, and a card that hid them
// would be the 91.9-FULL illusion with better CSS.
const flown = new Map();
for (const r of [...winners, ...marginalWinners].slice(0, flyCap)) {
for (const r of toFly) {
// clean winners fly the CLEAN tiers (what the audit recommends buying);
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
// Aligned by anchorId, never input order — attach reorders picks into ring
@ -174,6 +197,7 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
anchors, bed, stormDef, siteDef: site, use,
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
}));
await tickFly(r.ids.join(','));
}
const skipped = Math.max(0, winners.length + marginalWinners.length - flyCap);
@ -196,7 +220,12 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
sepStormName = site.separation.stormKey;
const sepStorm = sepStormDef ?? (sepStormName && sepStormName === stormName ? stormDef : null);
if (sepStorm) {
// Two flights (held + bare) on the block's own storm — announced before
// they run, because they are the one chunk left with no tick inside it.
onProgress?.({ phase: 'separation', done: 0, total: 1, label: sepStormName });
await yieldToEventLoop();
separation = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
onProgress?.({ phase: 'separation', done: 1, total: 1, label: sepStormName });
} else {
// Judging a pinned target on the wrong storm is worse than not judging it.
sepUnjudged = `pinned against ${sepStormName}, which the caller did not supply`;

View File

@ -110,6 +110,7 @@ import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.j
// with itself by construction.
import { createWindRouter } from '../../web/world/js/main.js';
import { buildScoringWorld } from './scorecard.js';
import { auditSweep, auditSweepAsync } from './sweep.js';
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@ -328,6 +329,74 @@ export async function buildScorecardTests() {
+ 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
}]);
// ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ───────────────
// scoreSite runs auditSweepAsync now — same flights, but the candidate loop
// yields so the editor page paints progress instead of freezing for 75 s.
// The contract is that yielding is PURE: any yieldEvery produces numbers
// byte-identical to the sync sweep. These asserts are the tripwire for
// anyone who later threads state across candidates (a reused rig, a shared
// accumulator) — the exact class of bug a chunk boundary would expose.
//
// Synthetic five-anchor yard (multiple candidates, so chunk boundaries land
// MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives
// in THREADS (site_02 + site_03 cards pinned before and after the refactor).
const YARD5 = [
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
{ id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 };
const YARD5_STORM = {
id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
};
// Strip nothing, hide nothing: the whole result must survive comparison.
const sweepJSON = (r) => JSON.stringify({
cands: r.cands, rows: r.rows, winners: r.winners,
marginalWinners: r.marginalWinners, verdict: r.verdict,
});
const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] });
const ticks = [];
const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) });
const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
yieldEvery: 3 });
tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => {
// vacuity guard first — a yard whose sweep found nothing would make the
// equality below a comparison of two empty objects
assert(syncResult.cands.length >= 3,
`gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — `
+ 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. '
+ 'Fix the fixture, not the assert.');
assert(sweepJSON(chunk1) === sweepJSON(syncResult),
'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep '
+ 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets '
+ 'computed — some state is leaking across candidates.');
}]);
tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => {
assert(sweepJSON(chunk3) === sweepJSON(chunk1),
'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk '
+ 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate '
+ 'flight is reading something a previous chunk wrote.');
}]);
tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => {
const sweepTicks = ticks.filter((p) => p.phase === 'sweep');
assert(sweepTicks.length === syncResult.cands.length,
`gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got `
+ `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't `
+ 'disease on the UI layer.');
sweepTicks.forEach((p, i) => {
assert(p.done === i + 1 && p.total === syncResult.cands.length,
`gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`);
});
}]);
// ── the clone is the same weather as the file ────────────────────────────
tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
const v = fun.editor.site.wind?.venturi ?? [];

View File

@ -93,6 +93,38 @@ export const tierFor = (peakN, ratingHint = 1) =>
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
*/
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
// 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 = cands.map((cnd) => priceCandidate(cnd, { anchors, stormDef, wind }));
return judgeSweep(cands, rows);
}
/**
* Step 1 of the sweep, alone: every quad in the rigging band that shades the
* bed, plus the site's pinned separation line. Extracted (SPRINT15 gate 2.1)
* so an ASYNC driver can enumerate the work before doing it a progress tick
* needs a denominator. Same code auditSweep always ran, one copy.
*/
export function findCandidates({ anchors, bed, siteDef = 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++)
@ -124,63 +156,59 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
}
}
return cands;
}
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
/**
* Fly ONE candidate and price its corners. Extracted (SPRINT15 gate 2.1) as
* the unit of chunked work: the async driver yields between calls to this so
* the page can paint, and because each call builds its own rig and flies the
* same wind at the same seconds, chunking cannot change a number the
* scorecard selftest asserts exactly that (chunked === sync, to the byte).
*/
export function priceCandidate(cnd, { anchors, stormDef, wind }) {
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
// 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);
// 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.
//
// 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);
const cleanHw = tiers.every((c) => c.cleanTier)
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
return { ...cnd, tiers, unholdable, marginal, hw, cleanHw,
total: hw + AUDIT.SPARE_COST,
affordable: !unholdable.length && hw <= START_BUDGET,
clean: cleanHw != null && cleanHw <= START_BUDGET };
}
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; 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.
//
// 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);
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 });
}
/**
* Sort the priced rows and hand down the verdict the tail of auditSweep,
* shared with the async driver. Sorts `rows` in place, exactly as auditSweep
* always has (Array.prototype.sort is stable, so chunked and sync drivers
* order ties identically).
*/
export function judgeSweep(cands, rows) {
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
// winners are lines the budget can buy at the CLEAN price (≥15% headroom on
@ -198,3 +226,61 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
: { ok: false, code: 'unaffordable', best: rows[0] },
};
}
/**
* auditSweep with a heartbeat same candidates, same flights, same verdict,
* but the candidate loop yields to the event loop every `yieldEvery` flights
* so a page driving it repaints instead of freezing. [SPRINT15 gate 2.1]
*
* D's verdict on the 7582 s blocking score: "made the editor batch, not
* iterative I scored once and shipped that run rather than tuning", and the
* worst part "is not the wait, it's not knowing whether it died". The fix is
* NOT a faster sim (the numbers must not move) it is telling the truth about
* progress while the same work happens.
*
* PURITY IS THE CONTRACT: every number this returns must equal auditSweep's
* to the byte, for any yieldEvery 1. That holds by construction each
* priceCandidate builds its own rig and flies a wind that is a pure function
* of (p, t) and by assert (scorecard.selftest.js runs sync vs yieldEvery 1
* vs yieldEvery 3 on the same yard and demands identical JSON). If you add
* state that survives across candidates, that assert is the tripwire.
*
* @param {function} [o.onProgress] called ({ phase:'sweep', done, total })
* after every flight cheap, DOM-free, caller renders it
* @param {number} [o.yieldEvery] flights per yield (macrotask); 1 = every flight
*/
export async function auditSweepAsync({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null,
onProgress = null, yieldEvery = 1 }) {
const cands = findCandidates({ anchors, bed, siteDef });
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
use?.(wind);
const rows = [];
for (let i = 0; i < cands.length; i++) {
rows.push(priceCandidate(cands[i], { anchors, stormDef, wind }));
onProgress?.({ phase: 'sweep', done: i + 1, total: cands.length });
if ((i + 1) % Math.max(1, yieldEvery) === 0) await yieldToEventLoop();
}
return judgeSweep(cands, rows);
}
/**
* One macrotask boundary long enough for the browser to paint, nothing else.
*
* MessageChannel, NOT setTimeout(0), and it is load-bearing: Chrome clamps
* chained timers in hidden/occluded tabs (1 s, then 1/minute under intensive
* throttling), which turned a 72 s score into one flight per MINUTE the first
* time this ran in an occluded pane measured, 1213 of 66 across 60 s.
* Message tasks are not timer-throttled, and the selftest header's own rule
* ("stays honest in a background tab") applies to the score as much as the
* suite. Falls back to setTimeout where MessageChannel is missing (node).
*/
export const yieldToEventLoop = typeof MessageChannel === 'undefined'
? () => new Promise((r) => setTimeout(r, 0))
: () => new Promise((r) => {
const ch = new MessageChannel();
ch.port1.onmessage = () => { ch.port1.close(); r(); };
ch.port2.postMessage(0);
});

View File

@ -0,0 +1,100 @@
// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit.
// Regenerate: blender -b -P tools/blender/build_yard_assets.py
//
// SPRINT15 gate 1.2 — the wire between the factory and the sim.
// adoptAnchor only carries a baked rating_hint to anchors that name a
// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted
// to `?? 1` regardless of the bake, and nobody could see the
// disagreement. This module is the factory's bakes, resolvable by
// anchor TYPE at runtime: `types` are unambiguous (every rated node
// of that type agrees), `ambiguous` lists the disagreements so the
// runtime can refuse to guess. ratings.js is the reader; e.test.js
// pins runtime === this module === the GLBs, so none of the three
// can drift from the others silently again.
export const FACTORY_ANCHOR_RATINGS = Object.freeze({
"ambiguous": {
"tree": {
"variants": [
{
"rating_hint": 1.0,
"sources": [
"tree_gum_01/branch_anchor_01",
"tree_gum_02/branch_anchor_01"
]
},
{
"rating_hint": 0.95,
"sources": [
"tree_jacaranda_01/branch_anchor_01"
]
},
{
"rating_hint": 0.88,
"sources": [
"tree_gum_01/branch_anchor_02",
"tree_gum_02/branch_anchor_02"
]
},
{
"rating_hint": 0.76,
"sources": [
"tree_gum_01/branch_anchor_03"
]
},
{
"rating_hint": 0.52,
"sources": [
"tree_jacaranda_01/branch_anchor_02"
]
},
{
"rating_hint": 0.4,
"sources": [
"tree_jacaranda_01/branch_anchor_03"
]
}
]
}
},
"types": {
"carport": {
"collateral": "carport",
"rating_hint": 0.22,
"sources": [
"carport_01/beam_anchor_01",
"carport_01/beam_anchor_02"
]
},
"carport_post": {
"collateral": "carport",
"rating_hint": 0.3,
"sources": [
"carport_01/post_anchor_01",
"carport_01/post_anchor_02"
]
},
"house": {
"collateral": "gutter",
"rating_hint": 0.35,
"sources": [
"house_yardside/fascia_anchor_01",
"house_yardside/fascia_anchor_02",
"house_yardside/fascia_anchor_03"
]
},
"post": {
"rating_hint": 1.0,
"sources": [
"sail_post/top_anchor"
]
},
"swing_frame": {
"collateral": "swing_set",
"rating_hint": 0.45,
"sources": [
"swing_set_01/frame_anchor_01",
"swing_set_01/frame_anchor_02"
]
}
}
});

View File

@ -69,20 +69,12 @@
import * as THREE from '../vendor/three.module.js';
import { smoothstep } from './weather.core.js';
import { loadStorm, windForSite } from './weather.js';
import { stormSel, STORM_KEYS } from './editor_storm.js';
/** Keep in step with data/storms/ — same list the game's STORMS carries. */
export const STORM_KEYS = [
'storm_01_gentle',
'storm_02_wildnight',
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
];
/** The southerly is the storm that makes a corner block scream a sensible
* place for an author to land, because a funnel authored under the gentle
* storm looks like it does nothing. */
const DEFAULT_STORM = 'storm_03_southerly';
/** Re-exported from the shared selection store ONE list, one copy. Sprint 14
* had this list here and a second copy in editor_score.js, which is how the
* page grew two pickers with two defaults. See editor_storm.js. */
export { STORM_KEYS };
/** Nominal canopy height for the shelter volume. A READING AID — see (4). */
const CANOPY_Y = 3.2;
@ -167,6 +159,46 @@ export function sampleWindField(wind, { width, depth, step = 1.5, t = 0 }) {
return { samples, dir, uniform, max, min };
}
/**
* The storm's WORST second on this yard: argmax of `speedAt` over a coarse
* grid × the whole storm. [SPRINT15, lane C's own-call polish]
*
* D authored site_03 cold and the scrubber's question was always "WHEN does
* this yard scream?" answered until now by dragging and watching, which is
* measurement by patience. This is the same question asked properly: scan the
* lattice, return the second. Pure same wind + same args = same answer on
* any machine (speedAt has no clock and no call-time RNG), and dt defaults to
* the scrubber's own 0.5 s step so the answer is always a second the scrubber
* can actually sit on.
*
* `probes` exist because a coarse grid can straddle a throat: the panel passes
* every authored venturi centre so the one point most likely to BE the worst
* is always scanned exactly.
*
* @returns {{t:number, speed:number, x:number, z:number}}
*/
export function worstSecond(wind, { width, depth, step = 3, dt = 0.5, probes = [] } = {}) {
const pts = [];
const nx = Math.max(2, Math.round(width / step));
const nz = Math.max(2, Math.round(depth / step));
for (let i = 0; i < nx; i++) {
for (let j = 0; j < nz; j++) {
pts.push({ x: -width / 2 + (i + 0.5) * (width / nx), z: -depth / 2 + (j + 0.5) * (depth / nz) });
}
}
for (const p of probes) pts.push({ x: p.x, z: p.z });
const n = Math.round(wind.duration / dt);
let best = { t: 0, speed: -Infinity, x: 0, z: 0 };
for (let k = 0; k <= n; k++) {
const t = k * dt;
for (const p of pts) {
const s = wind.core.speedAt(p.x, p.z, t);
if (s > best.speed) best = { t, speed: s, x: p.x, z: p.z };
}
}
return best;
}
/** Amplification → colour. Cool/dim = sheltered, white = untouched, hot = funnelled. */
function ratioColor(ratio, out) {
// 0.55 …… 1.0 …… 1.6 mapped blue → pale → red. The neutral band is
@ -190,9 +222,17 @@ export async function mountWindPanel(EDITOR) {
const { root, body } = EDITOR.mountPanel({ id: 'wind', title: 'WIND', order: 50 });
// --- state -------------------------------------------------------------
// Storm + second live in the SHARED selection store, not here — gate 2.2.
// This panel is the picker UI (the owner's controls), but the value it edits
// is THE page selection, the same one SCORE IT scores. Local `stormKey`/
// `time` are gone on purpose: state this panel privately held is exactly how
// D scored one storm while looking at another.
const SEL = stormSel();
const storms = {};
let stormKey = DEFAULT_STORM;
let time = 40;
/** Last worst-second scan, or null. Cleared on any site change the claim
* "this is the storm's worst second on this yard" dies the moment the yard
* moves (B's stale-score instinct, borrowed). Keyed by storm as well. */
let worstNote = null;
let showField = true;
let showShelters = true;
let gridStep = 1.5;
@ -209,14 +249,18 @@ export async function mountWindPanel(EDITOR) {
* game would fly, not ones re-derived from the JSON by hand.
*/
function buildWind() {
const def = storms[stormKey];
const def = storms[SEL.storm];
if (!def) return null;
return windForSite(def, EDITOR.site, EDITOR.world?.anchors ?? []);
}
function refreshWind() {
wind = buildWind();
if (wind && time > wind.duration) time = wind.duration;
// Clamp an over-long second back into the storm THROUGH the store, so the
// clamped value is the selection everyone shares, not a private display
// fix. The store's no-op guard is what keeps this from ringing: the emit
// this triggers re-enters here, finds time ≤ duration, and writes nothing.
if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration, 'wind-panel:clamp');
}
// --- gizmo drawing ------------------------------------------------------
@ -432,7 +476,7 @@ export async function mountWindPanel(EDITOR) {
if (!wind) return null;
const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
const field = sampleWindField(wind, {
width: yard.width, depth: yard.depth, step: gridStep, t: time,
width: yard.width, depth: yard.depth, step: gridStep, t: SEL.time,
});
if (showField) drawField(field);
if (showShelters) for (const s of wind.core.shelters) drawShelter(s, field.dir);
@ -580,29 +624,50 @@ export async function mountWindPanel(EDITOR) {
body.textContent = '';
const field = redraw();
// --- storm + second ---
// --- storm + second — views of the SHARED selection, gate 2.2 ---
const sel = el('select', 'ed-sel');
for (const k of STORM_KEYS) {
const o = el('option', null, k.replace(/^storm_/, ''));
o.value = k;
if (k === stormKey) o.selected = true;
if (k === SEL.storm) o.selected = true;
sel.append(o);
}
sel.addEventListener('change', async () => {
stormKey = sel.value;
await ensureStorm(stormKey);
refreshWind();
render();
sel.addEventListener('change', () => {
// Write the store, nothing else — the store's change is what re-renders,
// so a storm picked ANYWHERE (this select, B's panel, the console) walks
// the identical path through this panel.
SEL.setStorm(sel.value, 'wind-panel');
});
body.append(row('storm', sel));
const dur = wind?.duration ?? 90;
body.append(row('second', el('span', null, `${time.toFixed(1)} s / ${dur.toFixed(0)} s`)));
body.append(slider(0, dur, 0.5, Math.min(time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
time = v;
render();
body.append(row('second', el('span', null, `${Math.min(SEL.time, dur).toFixed(1)} s / ${dur.toFixed(0)} s`)));
body.append(slider(0, dur, 0.5, Math.min(SEL.time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
SEL.setTime(v, 'wind-panel');
}));
// Jump the SHARED second to the storm's worst moment on this yard — the
// scrubber question D kept answering by patience, answered by scan. Writes
// through the store like every other control, so SCORE IT and any future
// subscriber see the same landing.
const bPeak = el('button', 'ed-btn', 'JUMP TO WORST SECOND');
bPeak.addEventListener('click', () => {
if (!wind) return;
const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
const probes = (wind.core.venturi ?? []).map((v) => ({ x: v.x, z: v.z }));
worstNote = {
...worstSecond(wind, { width: yard.width, depth: yard.depth, probes }),
storm: SEL.storm,
};
SEL.setTime(worstNote.t, 'wind-panel:worst');
render(); // covers the no-op case (already sitting on it)
});
body.append(row('', bPeak));
if (worstNote && worstNote.storm === SEL.storm) {
body.append(row('worst', el('span', worstNote.speed > (field?.uniform ?? 0) * 1.15 ? 'ed-warn' : null,
`${worstNote.speed.toFixed(2)} m/s @ ${worstNote.t.toFixed(1)} s · (${worstNote.x.toFixed(1)}, ${worstNote.z.toFixed(1)})`)));
}
if (field) {
const card = el('div', 'ed-card');
const head = el('div', 'ed-card-head');
@ -714,10 +779,10 @@ export async function mountWindPanel(EDITOR) {
if (wind) {
const core = wind.core.venturi[i];
if (core) {
const d = wind.core.dirAt(time);
const d = wind.core.dirAt(SEL.time);
const align = Math.abs(Math.cos(d) * core.axisX + Math.sin(d) * core.axisZ);
const fired = Math.pow(align, core.sharp);
const at0 = wind.core.speedAt(core.x, core.z, time);
const at0 = wind.core.speedAt(core.x, core.z, SEL.time);
const r = el('div', 'ed-card-row');
r.append(el('span', 'ed-kv', 'now'),
el('span', fired > 0.5 ? 'ed-warn' : 'ed-kv',
@ -743,15 +808,29 @@ export async function mountWindPanel(EDITOR) {
if (!storms[k]) storms[k] = await loadStorm(k);
}
await ensureStorm(stormKey);
refreshWind();
render();
/**
* React to the shared selection. Async because a newly-picked storm may need
* fetching; the token drops a stale response two quick switches must not
* let the slower fetch paint the panel with the loser (the def cache makes
* this near-impossible to hit, and near-impossible is not a guard).
*/
let selGen = 0;
async function applySel() {
const gen = ++selGen;
await ensureStorm(SEL.storm);
if (gen !== selGen) return;
refreshWind();
render();
}
await applySel();
SEL.onChange(() => { applySel(); });
// A clears the overlay on every rebuild — so every rebuild, I redraw. My
// gizmos are a pure function of (site, storm, second), which is exactly why
// this is one line instead of a lifetime problem.
EDITOR.on('rebuild', () => { refreshWind(); render(); });
EDITOR.on('siteload', () => { selVenturi = 0; });
EDITOR.on('rebuild', () => { worstNote = null; refreshWind(); render(); });
EDITOR.on('siteload', () => { selVenturi = 0; worstNote = null; });
/**
* The wind is rebuilt HERE, on any 'change', rather than in my own commit().
*
@ -768,7 +847,7 @@ export async function mountWindPanel(EDITOR) {
* function of the site as it IS, no matter who changed it. render() never
* calls markDirty(), so this cannot loop.
*/
EDITOR.on('change', () => { refreshWind(); render(); });
EDITOR.on('change', () => { worstNote = null; refreshWind(); render(); });
return { root, body, redraw, buildWind, get wind() { return wind; } };
}

View File

@ -40,17 +40,12 @@
import { loadStorm } from './weather.js';
import { START_BUDGET } from './contracts.js';
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
/** The storms a yard gets judged against. `storm_02_wildnight` leads because
* it is the storm every Sprint-13 number was argued over score against the
* one people remember. */
const STORMS = [
'storm_02_wildnight',
'storm_01_gentle',
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
];
// SPRINT15 gate 2.2: the storm list and the SELECTION live in editor_storm.js
// now — C's converged store (storm + second; this panel ignores the second on
// purpose — a score flies the WHOLE storm). D scored the wildnight while
// viewing the southerly because this file and editor.wind.js each defaulted a
// private stormKey; neither carries one any more.
import { stormSel, STORM_KEYS } from './editor_storm.js';
const el = (tag, cls, text) => {
const n = document.createElement(tag);
@ -93,7 +88,14 @@ function boot() {
const rowStorm = el('div', 'ed-row');
rowStorm.append(el('span', 'ed-label', 'storm'));
const sel = el('select', 'ed-sel');
for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s));
for (const s of STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
// One storm selection for the whole page (gate 2.2): this select is a VIEW of
// C's store, not an owner — change it here and the wind panel follows, change
// it there and this follows. Scoring one storm while reading another's wind
// field is how D got quietly wrong numbers.
sel.value = stormSel().storm;
sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));
stormSel().onChange(({ storm }) => { sel.value = storm; });
rowStorm.append(sel);
body.append(rowStorm);
@ -108,7 +110,8 @@ function boot() {
const note = el('div', 'ed-note',
'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s '
+ 'view, whose wind is a calm stub. Every number flies windForSite() and the real '
+ 'commit→attach chain. Takes a few seconds; that is the point.');
+ 'commit→attach chain. Slow is honest — every candidate flies the full storm — '
+ 'but the page stays live and the progress line says where it is.');
body.append(note);
// A score describes the yard as it was WHEN SCORED. The moment anything
@ -129,14 +132,30 @@ function boot() {
stale = false;
btn.disabled = true;
btn.textContent = 'scoring…';
out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…'));
// yield so the button repaints before we block the thread for seconds
// The progress line (gate 2.1). D's verdict on the old blocking run: the
// worst part "is not the wait, it's not knowing whether it died". scoreSite
// yields between flights now, so every update here actually PAINTS.
const prog = el('div', 'ed-note', 'building a dressed world…');
out.replaceChildren(prog);
// yield so the button repaints before the first heavy chunk
await new Promise((r) => setTimeout(r, 0));
const t0 = performance.now();
const secs = () => `${((performance.now() - t0) / 1000).toFixed(1)} s`;
const onProgress = (p) => {
if (p.phase === 'sweep') {
prog.textContent = `sweeping — flight ${p.done}/${p.total} quads · ${secs()}`;
} else if (p.phase === 'fly') {
prog.textContent = `flying the garden — line ${p.done}/${p.total}${p.label ? ` · ${p.label}` : ''} · ${secs()}`;
} else if (p.phase === 'separation') {
prog.textContent = p.done
? `separation target judged · ${secs()}`
: `judging the pinned separation target (${p.label}) · ${secs()}`;
}
};
try {
const site = EDITOR.siteClone();
const stormName = sel.value;
const stormName = stormSel().storm;
const stormDef = await loadStorm(stormName);
// The separation block names its OWN storm — judging A's pinned target on
@ -146,7 +165,7 @@ function boot() {
const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null;
const built = await buildScoringWorld(site);
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built });
const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built, onProgress });
render(out, score, performance.now() - t0);
globalThis.__editorScore = score; // for the selftest + console spelunking
} catch (err) {

View File

@ -0,0 +1,133 @@
/**
* editor_storm.js THE storm + second selection for the editor page.
* [Lane C owns the file, SPRINT15 gate 2.2 one storm picker, CONVERGED with
* Lane B's THREADS proposal (2026-07-20): B's filename, B's global name
* (`EDITOR_STORM`), B's `source` argument and B's default storm are all taken;
* the API keeps my `storm/time` symmetric shape because the gate asks for a
* shared storm+TIME selection B's panel simply ignores the half it doesn't
* use. Countered-and-matched the same way we converged the gate-2.3 pin.]
*
* Sprint 14 shipped two storm pickers on one page: the WIND panel defaulted the
* southerly (mine), SCORE IT defaulted the wildnight (B's), and D scored the
* wildnight WHILE LOOKING AT the southerly two harnesses disagreeing inside a
* single page, the exact bug class this repo keeps paying for (three harnesses
* measured a funnel-off yard in Sprint 11 for the same species of reason).
*
* The fix is ownership, not UI: this module is the ONE owner of "which storm,
* which second" on the editor page. Panels are views of it any number of
* selects and scrubbers may DISPLAY the selection, but they all write here and
* they all re-read from here, so there is nothing for two panels to disagree
* about. Same shape as A's editor owning the site and every panel calling
* `markDirty()`: one place remembers, nobody else has to.
*
* The contract
* · `stormSel()` returns the page singleton (import identity both panels
* import this same specifier, so they cannot get different stores).
* · `setStorm(key)` REFUSES garbage (throws on a key not in STORM_KEYS)
* a selection that can silently hold a storm that doesn't exist is the
* `_v1`-suffix 404 one layer up.
* · No-op writes are SILENT: setting the value it already holds fires no
* listener and returns false. This is load-bearing, not politeness it is
* what lets a subscriber clamp the selection from inside its own change
* handler (wind panel: `time > duration` after a storm switch) without
* ringing forever.
* · `time` is seconds into the storm, 0, finite. The store deliberately
* does NOT know storm durations (it would have to fetch to learn them, and
* a synchronous store is the point) the wind panel, which has the loaded
* def, clamps over-long times back into the storm via the same setTime.
* · Listener errors are caught and logged, never propagated one broken
* panel must not sever the selection for the others (A's emit() rule).
*
* DEFAULT: the southerly at t=40 B's converged pick, and the reasons are
* authoring reasons, not courtesy: the wind overlay is the surface that is
* LIVE the moment the page opens and needs a storm that shows something (a
* funnel authored under the gentle storm looks like it does nothing); SCORE IT
* sits behind an explicit click; and site_03 the yard most likely to be
* iterated next was authored and played on the southerly. t=40 is the second
* my S14 receipt demonstrated: align 100%, peak gain ×1.46 on site_02. (My
* first cut defaulted the gate-2.3 pin, wildnight@60 retired when B argued
* the authoring case; the pin stays pinned in c.test.js either way, it never
* needed to be the default.) c.test.js asserts THIS pair so neither lane's
* refactor can silently regrow a private default.
*/
/** THE storm list, one copy. editor.wind.js re-exports it; keep in step with
* data/storms/ (c.test.js pins this list against its own, so drift is red). */
export const STORM_KEYS = [
'storm_01_gentle',
'storm_02_wildnight',
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
];
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };
/**
* A fresh store exported for c.test.js, which must be able to make throwaway
* instances without touching the page singleton.
*/
export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.time } = {}) {
if (!STORM_KEYS.includes(storm)) throw new Error(`[stormsel] unknown storm '${storm}'`);
const listeners = new Set();
const state = { storm, time };
const emit = (source) => {
// snapshot payload + snapshot listener set: a listener that unsubscribes
// (or subscribes) mid-emit must not skew delivery for the rest. `source`
// is B's ask: a debug string saying WHO wrote ('wind-panel', 'score',
// 'console'…), carried in the payload, never used for logic.
const payload = { storm: state.storm, time: state.time, source: source ?? null };
for (const fn of [...listeners]) {
try { fn(payload); } catch (err) { console.error('[stormsel] listener threw:', err); }
}
};
return {
get storm() { return state.storm; },
get time() { return state.time; },
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setStorm(key, source) {
if (!STORM_KEYS.includes(key)) {
throw new Error(`[stormsel] unknown storm '${key}' — keys: ${STORM_KEYS.join(', ')}`);
}
if (key === state.storm) return false;
state.storm = key;
emit(source);
return true;
},
/** @returns {boolean} true if the selection changed (false = no-op, silent) */
setTime(t, source) {
const v = Number(t);
if (!Number.isFinite(v) || v < 0) {
throw new Error(`[stormsel] time must be a finite second ≥ 0, got ${t}`);
}
if (v === state.time) return false;
state.time = v;
emit(source);
return true;
},
/** @returns {() => void} unsubscribe */
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
// --- the page singleton ------------------------------------------------------
// Module-import identity IS the sharing mechanism: editor.wind.js and
// editor_score.js import this same file, so stormSel() hands both the same
// object and there is no registration step to forget. Exposed on globalThis
// as EDITOR_STORM (B's name) for consoles and spelunking.
let _sel = null;
export function stormSel() {
if (!_sel) {
_sel = createStormSel();
if (typeof globalThis !== 'undefined') globalThis.EDITOR_STORM = _sel;
}
return _sel;
}

68
web/world/js/ratings.js Normal file
View File

@ -0,0 +1,68 @@
/**
* SHADES / HARD YARDS Lane E owns this file.
*
* The runtime end of the factory's anchor ratings (SPRINT15 gate 1.2).
*
* THE BUG THIS CLOSES: `adoptAnchor` is the only place a baked `rating_hint`
* ever reached an anchor, and it only runs for anchors that name a GLB `node`.
* Every shipped sail post is a plain JSON entry with no `node`, so the
* factory's bake never arrived and `?? 1` won the factory said 0.90, the sim
* played 1.00, and nothing anywhere could see the disagreement. Gate 1.1 ruled
* the shipped 1.00 canon (every measured number stands on it), and this module
* is the mechanism half of that ruling: the same silent divergence is now
* impossible for the whole extras family (`rating_hint`, `collateral`, and by
* exclusion `tie_off`) on ANY node-less JSON anchor whose type the factory
* rates.
*
* HOW: the factory emits `anchor_ratings.gen.js` from the re-imported GLBs
* per anchor TYPE, the baked extras, but only where every rated node of that
* type in the palette agrees (`types`). Types whose nodes disagree (tree:
* 1.0 0.4 down the trunk) are listed under `ambiguous`, and this module
* REFUSES to guess for them a node-less anchor of an ambiguous type keeps
* the default hint and warns loudly, and e.test.js pins that no shipped site
* declares one. Three-way agreement is pinned in e.test.js:
* GLB === manifest === runtime, so a re-bake that skips regeneration, a
* hand-edit to the gen file, or a future adoptAnchor-shaped gap all go red.
*
* WHO CALLS IT: world.js, at the JSON-anchor creation sites, as
* `Object.assign(makeStaticAnchor(...), ..., a.node ? null : factoryExtras(type))`
* node-carrying anchors keep their graybox default and are corrected by
* adoptAnchor at dress time, exactly as before; node-less ones take the
* factory's word at construction, because dress will never reach them.
*/
import { FACTORY_ANCHOR_RATINGS } from './anchor_ratings.gen.js';
/** Warn once per type, not once per anchor a site with four posts of a
* future ambiguous type should not print four identical warnings. */
const warned = new Set();
/**
* The factory's baked extras for an anchor TYPE, in runtime (camelCase) field
* names, ready to `Object.assign` onto an Anchor.
*
* @param {string} type An ANCHOR_TYPE string (contracts.js).
* @returns {{ratingHint: number, collateral?: string}|null}
* The extras when the factory rates this type unambiguously; null when it
* doesn't rate it at all (caller keeps its default) or rates it ambiguously
* (caller keeps its default AND this is almost certainly a site bug a
* node-less anchor of an ambiguous type has no honest rating; declare a
* `node` instead. e.test.js refuses it in shipped sites).
*/
export function factoryExtras(type) {
const t = FACTORY_ANCHOR_RATINGS.types[type];
if (t) {
const out = { ratingHint: t.rating_hint };
if (t.collateral !== undefined) out.collateral = t.collateral;
return out;
}
if (FACTORY_ANCHOR_RATINGS.ambiguous[type] && !warned.has(type)) {
warned.add(type);
console.warn(
`[ratings] anchor type "${type}" is factory-AMBIGUOUS (its GLB nodes carry `
+ `different rating_hints) — a node-less "${type}" anchor falls back to the `
+ `default hint, which is the exact silent-divergence bug gate 1.2 buried. `
+ `Declare a \`node\` on the anchor so adoptAnchor can carry the real bake.`);
}
return null;
}

View File

@ -23,7 +23,8 @@ import { loadSite, createWorld } from '../world.js';
// including on the day the game itself changed. "Two harnesses, one number"
// means reaching for the real one.
import { createWindRouter } from '../main.js';
import { normalizeAxis, shelterAtten } from '../editor.wind.js';
import { normalizeAxis, shelterAtten, worstSecond, STORM_KEYS as WIND_STORM_KEYS } from '../editor.wind.js';
import { createStormSel, stormSel, STORM_KEYS as SEL_STORM_KEYS, DEFAULT_SEL } from '../editor_storm.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -884,6 +885,127 @@ export default async function run(t) {
+ 'chain, so this sweep proves nothing about setSheltersFromTrees');
});
// =========================================================================
// GATE 2.2 (SPRINT15) — ONE STORM PICKER: the shared selection store
//
// Sprint 14's page had two pickers with two defaults, and D scored the
// wildnight while looking at the southerly. editor_storm.js is the one
// owner now; these asserts pin its contract. The DOM half (my select and
// scrubber writing/reading it) cannot run here and is verified live on the
// editor page — said plainly rather than papered over with a fake DOM.
t.test('GATE 2.2: the shared storm selection refuses garbage, is silent on no-ops, and opens on the CONVERGED default', () => {
// The default is the pair B and C agreed in THREADS (B's storm — the
// authoring case: the wind overlay is live at page-open and the southerly
// makes the funnel scream; my second — t=40 is the S14 receipt's align-100%
// moment). Pinned HERE so neither lane's refactor can silently regrow a
// private default: two defaults was the whole bug.
assert(SEL_STORM_KEYS.includes(DEFAULT_SEL.storm), 'the default storm is not a real storm');
assert(DEFAULT_SEL.storm === 'storm_03_southerly' && DEFAULT_SEL.time === 40,
`the default selection (${DEFAULT_SEL.storm} @ ${DEFAULT_SEL.time}s) has drifted off the `
+ 'converged storm_03_southerly @ 40s — that pair is a B+C agreement (THREADS 2026-07-20), '
+ 'renegotiate it there before moving it here');
const s = createStormSel();
assert(s.storm === DEFAULT_SEL.storm && s.time === DEFAULT_SEL.time, 'a fresh store ignores the default');
const seen = [];
const off = s.onChange((p) => seen.push(p));
// a real change fires exactly once, payload matches the getters, and the
// writer's `source` tag (B's ask) rides the payload for debugging
assert(s.setStorm('storm_02_wildnight', 'c.test') === true, 'a real storm change should return true');
assert(seen.length === 1 && seen[0].storm === 'storm_02_wildnight' && seen[0].time === DEFAULT_SEL.time,
`one change should mean one event with the new value, got ${JSON.stringify(seen)}`);
assert(seen[0].source === 'c.test',
`the payload should carry the writer's source tag, got ${JSON.stringify(seen[0])}`);
// no-op writes are SILENT — this is what lets a subscriber clamp the
// selection from inside its own change handler without ringing forever
assert(s.setStorm('storm_02_wildnight') === false, 'a no-op storm write should return false');
assert(s.setTime(s.time) === false, 'a no-op time write should return false');
assert(seen.length === 1, `a no-op fired a listener — the anti-loop guard is gone (${seen.length} events)`);
// garbage refused, loudly, without moving the selection or ringing
for (const bad of ['storm_99_nope', '', null]) {
let threw = false;
try { s.setStorm(bad); } catch { threw = true; }
assert(threw, `setStorm(${JSON.stringify(bad)}) was accepted — a selection that can hold a `
+ 'storm that does not exist is the _v1-404 bug one layer up');
}
for (const bad of [NaN, -1, Infinity, 'soon']) {
let threw = false;
try { s.setTime(bad); } catch { threw = true; }
assert(threw, `setTime(${JSON.stringify(bad)}) was accepted`);
}
assert(s.storm === 'storm_02_wildnight' && seen.length === 1,
'refused garbage moved the selection or fired a listener anyway');
// a throwing listener must not sever delivery to the others
const order = [];
s.onChange(() => { order.push('a'); throw new Error('deliberate'); });
s.onChange(() => order.push('b'));
s.setTime(12.5);
assert(order.join('') === 'ab', `a throwing listener severed its neighbours (delivered: ${order})`);
// unsubscribe works: `seen` has the storm change + the 12.5 s write = 2,
// and the write AFTER off() must not add a third
off();
s.setTime(20);
assert(seen.length === 2, `unsubscribed listener still delivered (${seen.length} events)`);
});
t.test('GATE 2.2: one singleton, one storm list — every consumer holds the same objects', () => {
// Import identity is the sharing mechanism, so it is the thing to pin.
assert(stormSel() === stormSel(), 'stormSel() built two stores — the page is back to two pickers');
assert(globalThis.EDITOR_STORM === stormSel(),
"the console handle (B's EDITOR_STORM name, converged) points at a different store");
// editor.wind.js re-exports the store's list — SAME ARRAY, not a copy that
// can drift (two copies of this list is how the page grew two pickers).
assert(WIND_STORM_KEYS === SEL_STORM_KEYS, 'editor.wind.js carries its own storm list again');
// and the shared list agrees with this suite's (which the node runner
// keeps honest against data/storms/)
const a = [...SEL_STORM_KEYS].sort().join(',');
const b = [...STORMS].sort().join(',');
assert(a === b, `the shared storm list and the suite's disagree:\n store: ${a}\n suite: ${b}`);
});
t.test('worst-second finder: deterministic, self-consistent, and it cannot miss the pinned throat moment', () => {
// The JUMP TO WORST SECOND button (SPRINT15 polish) is a claim about the
// whole storm, so it gets the same discipline as any other number.
const wind = editorWind(); // site_02 × wildnight, dressed
const yard = pinSite.yard ?? { width: 30, depth: 20 };
const args = {
width: yard.width, depth: yard.depth,
probes: [{ x: PIN.probe.x, z: PIN.probe.z }], // the throat, as the panel passes it
};
const a = worstSecond(wind, args);
const b = worstSecond(wind, args);
assert(a.t === b.t && a.speed === b.speed && a.x === b.x && a.z === b.z,
`two identical scans disagreed: ${JSON.stringify(a)} vs ${JSON.stringify(b)} — the scan is not pure`);
assert(Number.isFinite(a.speed) && a.speed > 0, `vacuous scan: ${JSON.stringify(a)}`);
// the reported worst is a real sample, not an accumulator artefact
assert(a.speed === wind.core.speedAt(a.x, a.z, a.t),
`worst.speed ${a.speed} ≠ speedAt(worst) ${wind.core.speedAt(a.x, a.z, a.t)}`);
// dominance at a point ON the scan lattice: the throat probe is scanned
// exactly, and t=60.0 sits on the 0.5 s lattice, so the argmax reporting
// anything BELOW the pinned throat moment means the comparator or the
// probe wiring is broken. (An off-lattice point could legitimately exceed
// the scan; an on-lattice one cannot.)
const throatAtPin = wind.core.speedAt(PIN.probe.x, PIN.probe.z, PIN.t);
assert(a.speed >= throatAtPin,
`worst ${a.speed} m/s < throat@pin ${throatAtPin} m/s — the scan missed a lattice point it was given`);
// probes are WIRED, proven by making them the only way to win: a tiny grid
// pinned to the yard centre sits > 6 m from the throat — outside the
// funnel's 4 m radius, so no grid point ever sees the gain — while the
// throat probe does. If the probe list is dropped, the winner cannot be at
// the throat. (Deterministic: same scan, same winner, every run.)
const c = worstSecond(wind, { width: 4, depth: 4, step: 3, probes: [{ x: PIN.probe.x, z: PIN.probe.z }] });
assert(c.x === PIN.probe.x && c.z === PIN.probe.z,
`with the grid held off the funnel, the throat probe should win the scan — winner was `
+ `(${c.x}, ${c.z}) at ${c.speed} m/s, so the probes argument is not reaching the lattice`);
});
// =========================================================================
// The gizmos tell the truth about the maths they draw

View File

@ -21,6 +21,8 @@
import * as THREE from '../../vendor/three.module.js';
import { assert } from '../testkit.js';
import { ANCHOR_TYPE } from '../contracts.js';
import { createWorld, loadSite } from '../world.js';
import { FACTORY_ANCHOR_RATINGS } from '../anchor_ratings.gen.js';
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
//
@ -799,4 +801,105 @@ export default async function run(t) {
assert(g.scene.getObjectByName(state), `${state} missing — Lane A toggles these by name`);
}
});
// -------------------------------------------------------------------------
// SPRINT15 gate 1.2 — RULE 4, the third pin of the S14 family: no silent
// anchors, no unpriced collateral, and now NO BAKED HINT THAT NEVER ARRIVES.
//
// The bug this buries: `adoptAnchor` was the only wire from a baked
// `rating_hint` to a live anchor, and it only runs for anchors that name a
// GLB `node`. Every shipped sail post is a plain JSON entry with no node, so
// the factory said 0.90, `?? 1` said 1.00, and the sim won BY ACCIDENT for
// fourteen sprints. Gate 1.1 ruled the shipped 1.00 canon — which means the
// values now agree BY CONSTRUCTION, so these pins compare runtime against
// the generated MANIFEST (anchor_ratings.gen.js), never against a literal:
// a pin that hard-codes 1.0 would have been green through the entire bug.
//
// Two directions, so nothing can drift silently:
// RULE 4a manifest === GLBs (a re-bake that skips regeneration → red)
// RULE 4b runtime === manifest (the adoptAnchor-shaped gap itself → red)
// -------------------------------------------------------------------------
t.test('RULE 4a — the ratings manifest matches the shipped GLBs, both directions', () => {
const types = FACTORY_ANCHOR_RATINGS.types, ambig = FACTORY_ANCHOR_RATINGS.ambiguous;
assert(Object.keys(types).length > 0,
'vacuous: the manifest rates nothing — regenerate anchor_ratings.gen.js');
const nodeOf = (src) => {
const [asset, node] = src.split('/');
return loaded.get(asset)?.scene.getObjectByName(node);
};
// manifest → GLB: every source it cites exists and carries the same bake.
for (const [ty, entry] of Object.entries(types)) {
assert(entry.sources.length > 0, `manifest type "${ty}" cites no sources`);
for (const src of entry.sources) {
const o = nodeOf(src);
assert(o, `manifest type "${ty}" cites ${src}, which is not in the palette — stale manifest, regenerate`);
assert(o.userData?.rating_hint === entry.rating_hint,
`${src} bakes rating_hint ${o.userData?.rating_hint} but the manifest says ${entry.rating_hint}` +
'the factory and the manifest disagree; regenerate, do not hand-edit');
assert((o.userData?.collateral ?? null) === (entry.collateral ?? null),
`${src} bakes collateral ${JSON.stringify(o.userData?.collateral)} but the manifest says ` +
`${JSON.stringify(entry.collateral)} — regenerate`);
}
}
// GLB → manifest: every rated, non-denying anchor node is accounted for.
for (const [asset, o] of allNodes()) {
const ty = o.userData?.anchor_type;
if (ty === undefined || o.userData?.tie_off === false) continue;
assert(types[ty] || ambig[ty],
`${asset}/${o.name} is typed "${ty}", which the manifest does not know — regenerate`);
if (types[ty]) {
assert(o.userData?.rating_hint === types[ty].rating_hint,
`${asset}/${o.name} bakes ${o.userData?.rating_hint} but manifest type "${ty}" says ` +
`${types[ty].rating_hint} — either regenerate, or this type just became ambiguous ` +
'and the manifest must say so');
}
}
});
// RULE 4b needs live worlds; Suite.test() refuses async fns, so the awaits
// happen out here and the assert stays synchronous.
const siteWorlds = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
const site = await loadSite(name);
siteWorlds.push({ name, site, world: createWorld(new THREE.Scene(), { site }) });
}
t.test('RULE 4b — a node-less JSON anchor of a factory-rated type carries the factory rating at runtime', () => {
const types = FACTORY_ANCHOR_RATINGS.types, ambig = FACTORY_ANCHOR_RATINGS.ambiguous;
let checked = 0;
for (const { name, site, world } of siteWorlds) {
// Every anchor a site DECLARES in JSON, with the same type defaults
// world.js applies at each creation site.
const declared = [
...(site.posts ?? []).map((p) => ({ ...p, type: p.type ?? 'post' })),
...(site.house?.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'house' })),
...(site.trees ?? []).flatMap((tr) => (tr.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'tree' }))),
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => ({ ...a, type: a.type ?? 'post' }))),
].filter((a) => !a.node); // node-carrying anchors are adoptAnchor's job at dress
for (const d of declared) {
assert(!ambig[d.type],
`${name}/${d.id}: a node-less anchor of AMBIGUOUS type "${d.type}" has no honest ` +
'rating — the runtime refuses to guess (ratings.js); declare a `node` instead');
const entry = types[d.type];
assert(entry,
`${name}/${d.id}: type "${d.type}" is not factory-rated at all — bake a rating_hint ` +
'in build_yard_assets.py or this anchor plays at the default hint forever');
const rt = world.anchor(d.id);
assert(rt, `${name}/${d.id}: declared in JSON but absent from world.anchors`);
assert(rt.ratingHint === entry.rating_hint,
`${name}/${d.id} plays at ratingHint ${rt.ratingHint}; the factory baked ` +
`${entry.rating_hint} — the baked hint never arrived. This is the sail_post ` +
'0.90/1.00 bug shape: factory and sim disagreeing with no error anywhere');
assert((rt.collateral ?? null) === (entry.collateral ?? null),
`${name}/${d.id} plays at collateral ${JSON.stringify(rt.collateral)}; the factory ` +
`baked ${JSON.stringify(entry.collateral)} — same wire, same bug shape`);
checked += 1;
}
}
assert(checked > 0,
'vacuous: no node-less declared anchors found across the shipped sites — this pin checked nothing');
});
for (const { world } of siteWorlds) world.dispose();
}

View File

@ -13,6 +13,13 @@
import * as THREE from '../vendor/three.module.js';
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
// SPRINT15 gate 1.2 — E's patch, applied verbatim by the integrator at merge
// (the leadFor precedent): a node-less anchor takes the factory's word at
// construction, because dress() will never reach it. Object.assign ignores
// null, so each hunk is inert exactly where adoptAnchor already owns the job.
// At the ruled 1.00 this changes ZERO shipped numbers; it exists for the NEXT
// asset. E's e.test pins runtime === manifest so divergence can't be silent.
import { factoryExtras } from './ratings.js';
import { validateSiteWind } from './weather.core.js';
/**
@ -350,6 +357,7 @@ export function createWorld(scene, opts = {}) {
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
{ work: a.work },
a.node ? null : factoryExtras(a.type ?? 'house'),
));
}
@ -464,6 +472,7 @@ export function createWorld(scene, opts = {}) {
anchors.push(Object.assign(
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
{ work: spec.work },
spec.node ? null : factoryExtras(spec.type ?? 'post'),
));
}
@ -532,6 +541,7 @@ export function createWorld(scene, opts = {}) {
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
{ work: a.work },
a.node ? null : factoryExtras(a.type ?? 'post'),
));
}
}

Binary file not shown.