Compare commits

...

11 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
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
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
10 changed files with 1176 additions and 41 deletions

View File

@ -6903,6 +6903,217 @@ 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,
@ -7025,3 +7236,149 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
`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

@ -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; } };
}

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.