Compare commits

...

5 Commits

Author SHA1 Message Date
m3ultra
3c313f593f site_audit: createWorld needs a site now — feed verifyPosts the shipped JSON
A's gate-1 merge made createWorld REQUIRE a site (throws without one), which
crashed this tool's post cross-check: it called createWorld(scene, {wind}) with
no site. Fixed by reading the shipped data/sites/backyard_01.json and passing it
(loadSite fetch()es, so it can't run in node — read the file directly). The
cross-check is now BETTER: it verifies the dressed-snapshot posts against the
actual shipped site data, raked in createWorld, rather than against code.

Built-in audit green again; dress-source refusal (exit 2) and resolved-export
(exit 0) paths unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:59:58 +10:00
m3ultra
bb1106efec THREADS: SPRINT10 Lane B — answer C (porosity wired+proven), flag dress-source site JSON to A, tree offer to E
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:55:11 +10:00
m3ultra
d222a1e0dd site_audit: refuse dress-source site JSON instead of false-unwinnable
A's committed site schema (SPRINT10 world.js loadSite/createWorld) is
dress-source, and my loadSite assumed resolved positions. Run head-on against
A's real data/sites/backyard_01.json the tool reported:

    ✗ FAIL — no quad in the band shades the bed at all. The site cannot be rigged.

That is a lie, and the dangerous kind: the site is fine; the TOOL couldn't read
it. It is the SPRINT6 unwinnable-site trap in reverse — a false negative that,
in a "every site runs the audit before it ships" workflow, condemns a good site.

Two things make the schema unreadable headless, both real:
  · posts are pre-rake. JSON carries {id,x,z,h}; world.js:361 leans each post 8°
    off centre. p4's spec (-3.2,-1.2) dresses to (-3.72,-1.40) — the exact 0.56 m
    error this tool's own snapshot shipped in SPRINT9. Reading x/z raw re-makes it.
  · house/tree anchors have no coordinates — only `node`, a GLB empty's name.
    Their position exists only after dress() reads matrixWorld, and dress() needs
    GLTFLoader + fetch, neither of which runs in node. Branch anchors are exactly
    where the dangerous quads live (t2b at 10 kN), so dropping them silently is
    the worst possible failure.

The correct source of dressed positions is createWorld(site).anchors, which is
browser-only. That path lands once A's data-driven createWorld is in main; until
then loadSite REFUSES dress-source JSON with the reason and the plan, and exits 2
(distinct from the winnability FAIL's exit 1). The built-in DRESSED snapshot and
a resolved { anchors:[{id,type,pos}] } export both still audit. Raised to A in
THREADS: audit in-browser off createWorld(site).anchors, or export resolved anchors.

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

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

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

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

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

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

Selftest 288 passed, 0 failed, 0 skipped.

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

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

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

Two things earned their place:

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:34:29 +10:00
7 changed files with 470 additions and 97 deletions

View File

@ -2947,3 +2947,69 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
open on carports until landed), C's venturi (landed, waiting on site JSON shape), E's carport
(landed as a data-driven trap, waiting on site_02), B's site_audit tool (from the quad sweep).
Gate 3 (John plays the week) remains open — the game has never been more ready for it.
---
## SPRINT10 — Lane B — 2026-07-17
**C — your porosity → gardenHailExposure seam: already closed, no one-liner needed.** You asked
(THREADS ~2764) whether to make `gardenHailExposure` read `sail.porosity`, or leave it to me. It's
done — I wired it in SPRINT9, commit `e576f5c`, and it's in merged main: `skyfx.step` refreshes
`sailPorosity` from `world.sail.porosity` (skyfx.js:738), and `gardenHailExposure` folds
`hailBlockFor(size, sailPorosity)` into what it returns (skyfx.js:707). It's a no-op for membrane
exactly as you specified. So skip the one-liner — the seam is wired, and `main.js:764` consumes it in
the storm phase.
**But "wired" was untested end-to-end, and now it isn't.** The only test of the leak,
`weather.selftest`'s `'fabric choice is real'`, REIMPLEMENTS the formula inline (`hailBlockFor` +
`hailAt`) — it proves the primitive and the arithmetic, not the plumbing. A regression in the
plumbing (the :738 refresh dropped, `wind.def.hail.size` resolving wrong, `hailBlockFor` no longer
folded in) leaves it green while the game quietly stops leaking hail. That's the measure-a-copy
pattern this whole browser suite exists to avoid. New assert in balance.test:
`'porous cloth leaks pea hail into the garden, membrane blocks it'` drives the REAL
`sky.gardenHailExposure`:
pea (storm_03, size 0.7): cloth 0.346 vs membrane 0.292 → porous leaks +18%
ice (storm_02b, size 1.4): cloth 0.458 vs membrane 0.458 → identical (the no-op)
Isolation is exact: ONE settled intact rig, ONE hail instant, porosity flipped 0.30↔0 between reads
(`sky.step(0, t, {sail})` refreshes sailPorosity and rebuilds the shadow grid at the same instant
without advancing physics). No second rig — deliberately: two rigs whose corners diverge share no
shadow, and membrane cascades on the ice night, so the naive two-flight version reads a FALSE
ice-night difference. And it can fail: unwire porosity and both reads collapse to the membrane value
(0.2917 == 0.2917) → red. Selftest **288 pass / 0 fail**.
**⚠️ A — my site_audit cannot read your site JSON headless, and this shapes gate 2.** I ran the tool
on your committed `data/sites/backyard_01.json` (origin/lane/a). It reported *"no quad shades the bed
— the site cannot be rigged."* That's the SPRINT6 unwinnable trap IN REVERSE: a false negative, the
site is fine, the TOOL can't read the schema. Two reasons, both real and both by YOUR design (which is
correct for the game — I'm flagging what it costs a headless auditor):
1. **Posts are pre-rake.** The JSON has `{id,x,z,h}`; `world.js:361` leans each post 8° off centre
before it's an anchor. `p4`'s spec `(-3.2,-1.2)` dresses to `(-3.72,-1.40)` — the *exact* 0.56 m
error my own snapshot shipped in SPRINT9. A tool reading `x/z` raw re-commits it.
2. **House/tree anchors carry no coordinates** — only `node` (a GLB empty name). Their world
position exists only after `dress()` reads `matrixWorld`, and `dress()` needs GLTFLoader + fetch,
neither of which runs in node. The branch anchors are exactly where the dangerous quads live
(`t2b` at 10 kN in SPRINT9), so a headless tool that drops them silently under-reports the worst
corners — the failure this tool exists to prevent.
The single source of dressed positions is `createWorld(site).anchors`. So the audit's real home is
**in the browser, off your `createWorld(site)`** — zero drift, handles rake and GLB natively. That's
what I'll wire the moment your loadSite/createWorld lands in **main** (it's on lane/a now; I don't
want to build against an unmerged, still-"proposed" API). Until then the tool REFUSES dress-source
JSON with this reason and exits 2 (distinct from a real winnability FAIL's exit 1), and still audits
the built-in dressed snapshot + any resolved `{anchors:[{id,type,pos}]}` export.
**Your call, A:** (a) I audit `site_02` in-browser via `createWorld(site).anchors` once you merge — my
preference, it's the game's own truth; or (b) if you want a fast headless/CI path, emit a resolved
`anchors` array (dressed x/y/z) alongside the dress-source site, and I'll read that too. Either works;
(a) needs nothing from you but the merge.
**E — your standing offer to move the tree still stands, and I'll take you up on it the moment I can
actually audit `site_02`** (browser path, post-A-merge). I can't call a winnable line on the corner
block until its carport/tree anchors resolve, and those are your GLB's — so the audit and your tree
nudge are both downstream of A's gate 1. Ready to run the instant it lands.
site_02 audit: **BLOCKED on A's gate 1** (no `data/sites/site_02_corner_block.json` on any branch yet).
Everything else on my plate is done: C answered + proven, the tool is schema-aware and safe.

View File

@ -91,6 +91,12 @@ const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
/**
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
* The one guard-rail a hand-typed yard can actually have.
*
* SPRINT10: world.js stopped being code createWorld now REQUIRES a site and
* throws without one. It reads the site from data/sites/backyard_01.json, which
* is exactly the extraction A shipped, so this cross-check now compares the dump
* against the DATA's posts (raked in createWorld, no dress needed). loadSite()
* itself fetch()es and can't run in node, so read the JSON and hand it over raw.
*/
async function verifyPosts(site) {
const THREE = await import('../../web/world/vendor/three.module.js');
@ -100,7 +106,8 @@ async function verifyPosts(site) {
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
const siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
const drift = [];
for (const a of site.anchors) {
@ -119,7 +126,44 @@ async function verifyPosts(site) {
async function loadSite(path) {
if (!path) return BACKYARD_01;
const j = JSON.parse(await readFile(path, 'utf8'));
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
// A's committed site schema (SPRINT10, world.js loadSite/createWorld) is
// DRESS-SOURCE, not resolved positions, and headless node cannot turn one into
// the other. Two reasons, both load-bearing for an audit that must not lie:
//
// · POSTS are pre-rake. The JSON carries {id,x,z,h}; world.js:361 leans every
// post 8° away from the yard centre before it becomes an anchor. p4's JSON
// spec (-3.2,-1.2) dresses to (-3.72,-1.40) — 0.56 m, the exact error this
// tool's own snapshot shipped with in SPRINT9. Reading x/z raw re-makes it.
// · HOUSE and TREE anchors have NO coordinates at all — just `node`, a name
// baked into E's GLB (fascia_anchor_01, branch_anchor_02…). Their world
// position exists only after dress() reads the empty's matrixWorld, and
// dress() needs GLTFLoader + fetch, neither of which runs in node.
//
// So a headless read of this file gets posts wrong and tree/house anchors not
// at all — and the branch anchors are exactly where the dangerous quads live
// (t2b pulled 10 kN in SPRINT9). Reporting on that silently is the SPRINT6
// trap in reverse: a site called unriggable because the TOOL couldn't read it.
//
// The correct source of dressed positions is createWorld(site).anchors — which
// is browser-only. Until that path lands (this is raised to A in THREADS), the
// honest move is to refuse this schema loudly, not to guess at it.
const dressSource = Array.isArray(j.posts) || j.house || Array.isArray(j.trees) || Array.isArray(j.structures);
const resolved = Array.isArray(j.anchors) && j.anchors.length
&& j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x));
if (dressSource && !resolved) {
throw new Error(
`"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` +
` positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` +
` house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` +
` Audit it in the browser off createWorld(site).anchors — the single source of dressed\n` +
` positions — or hand this tool a site with a resolved { anchors:[{id,type,pos:{x,y,z}}] }\n` +
` array. Run with no argument to audit the built-in DRESSED backyard_01 snapshot.\n` +
` (This limitation and the browser-audit plan are in THREADS for Lane A.)`);
}
// Resolved-positions shape: a flat anchors[] each carrying real coordinates.
// This is what a dressed export (or a future createWorld dump) would hand us.
const anchors = (j.anchors || []).map((a) => ({
id: a.id, type: a.type || 'post',
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },

View File

@ -0,0 +1,86 @@
{
"id": "backyard_01",
"name": "The Backyard",
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
"yard": { "width": 30, "depth": 20 },
"sun": { "elevationDeg": 55, "azimuthDeg": -125 },
"gardenBed": { "x": 1, "z": 2, "w": 6, "d": 4 },
"house": {
"model": "house_yardside_v1",
"x": 0, "z": -10.5,
"anchors": [
{ "id": "h1", "node": "fascia_anchor_01", "type": "house", "work": "bracket" },
{ "id": "h2", "node": "fascia_anchor_02", "type": "house", "work": "bracket" },
{ "id": "h3", "node": "fascia_anchor_03", "type": "house", "work": "bracket" }
]
},
"trees": [
{
"id": "t1", "model": "tree_gum_01_v1",
"x": -9, "z": 2, "phase": 0.7, "trunkH": 4.2, "anchorY": 3.4,
"anchors": [
{ "id": "t1", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
{ "id": "t1b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" },
{ "id": "t1c", "node": "branch_anchor_03", "type": "tree", "work": "cloth" }
]
},
{
"id": "t2", "model": "tree_gum_02_v1",
"x": 8, "z": -2, "phase": 2.9, "trunkH": 3.8, "anchorY": 3.1,
"anchors": [
{ "id": "t2", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
{ "id": "t2b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" }
]
}
],
"posts": [
{ "id": "p1", "x": -4.5, "z": 5.5, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "p2", "x": 4.0, "z": 6.0, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "p3", "x": 0, "z": 7.0, "h": 4.0, "type": "post", "work": "cloth" },
{
"id": "p4", "x": -3.2, "z": -1.2, "h": 4.0, "type": "post", "work": "cloth",
"_why": [
"DO NOT move this post toward the bed. Its position is swept, not chosen, and it is the",
"single number holding DESIGN.md's central tension open.",
"",
"Before p4 the wild night had NO winnable line, for a geometric reason: every anchor near",
"the bed (p1/p2/p3) is SOUTH of it and nothing stands north short of the house 10 m away,",
"so covering rigs had to span the yard and died while rigs small enough to survive sat",
"beside the bed rather than over it. p4 supplies the missing north-west corner.",
"",
"Swept against the smallest quad covering 90% of the bed:",
" (-2.2, -1.2) -> 44.4 m2 DO NOT: full coverage lands INSIDE the survivable band,",
" so covering the bed stops costing risk and the rigging",
" puzzle loses its wrong answers.",
" (-3.2, -1.2) -> 48.6 m2 <- here (with the 8 deg rake the top anchor lands at",
" -3.72,-1.40 and full coverage costs 51.6 m2)",
" (-3.2, -2.0) -> 51.7 m2",
" (-4.2, -3.0) -> 60.0 m2",
" beyond z=-4 -> 63.5 m2, i.e. too far out to matter at all",
"",
"Full coverage costs 63.5 m2 without it and 51.6 with: a real new option, still bigger",
"than the 23-38 m2 rigs that survive unaided. a.test.js asserts the >45 m2 floor, so",
"moving this inward goes red — that assert is the tension's only guard."
]
}
],
"shed": { "model": "shed_01_v1", "x": 11.8, "z": 6.2, "rotYDeg": -90 },
"shedTable": { "model": "shed_table_v1", "x": 9, "z": 6, "rotYDeg": -90, "pickupNode": "pickup_anchor" },
"gnome": { "model": "garden_gnome_01_v1", "x": 4.3, "z": 4.4, "rotYDeg": -126, "collateralValue": 25 },
"fence": {
"sides": ["south", "west", "east"]
},
"wind": {
"venturi": []
}
}

View File

@ -15,7 +15,7 @@
import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld } from './world.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { SailRig, createSailView } from './sail.js';
@ -376,7 +376,13 @@ export async function boot(opts = {}) {
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
const world = createWorld(scene, { wind });
// SPRINT10: the yard is data. `site` decides which one, and the week hands it
// over per night — night three is the corner block.
const site = await loadSite(opts.site ?? week.site);
const world = createWorld(scene, { wind, site });
// Lane C: a venturi is a shelter's opposite — a gap SPEEDS wind along its axis.
// Absent/empty is a perfect no-op, which is what keeps backyard_01 identical.
wind.setVenturi?.(site.wind?.venturi ?? []);
// Lane E's GLBs land over the graybox. Awaited here because it resolves
// world.shedTable onto E's baked pickup_anchor, and wireYardActions (below,
// inside rigSail) reads that position when it registers the spare pickup.

View File

@ -5,7 +5,7 @@
import * as THREE from '../../vendor/three.module.js';
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWorld, heightAt } from '../world.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
@ -16,7 +16,10 @@ import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
const scene = new THREE.Scene();
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
// SPRINT10: the yard is data now. Loading the REAL site rather than a fixture
// is the point — these asserts are the proof that the extraction moved nothing.
const site = await loadSite('backyard_01');
const world = createWorld(scene, { wind: createStubWind({ calm: true }), site });
// Dress the yard before asserting anything about it: anchors are only FINAL
// after dress(), which moves them onto the positions Lane E baked and adds the

View File

@ -64,7 +64,12 @@ async function buildYard() {
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
};
const world = createWorld(scene, { wind: calm });
// SPRINT10 (Lane A, in Lane B's file — minimal, shout if you'd rather it read
// differently): the yard is data now, so createWorld needs the site. Loading
// the real backyard_01 keeps this suite's "read the yard, never copy it" rule
// intact — it's the same yard, from its source instead of from code.
const { loadSite } = await import('../world.js');
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
const anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
@ -329,6 +334,63 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se
};
}
/**
* Read the REAL sky.gardenHailExposure over the bed at one hail instant, with the
* sail's porosity swapped between shade cloth (0.30) and membrane (0) and NOTHING
* else touched. Returns { cloth, membrane } exposure numbers.
*
* This exists to close a gap C's fabric-hail seam left open. porosity now feeds
* the garden hail score in production rig.porosity -> skyfx.step reads it into
* sailPorosity (skyfx.js) -> gardenHailExposure folds hailBlockFor(size, porosity)
* into what it returns (wired SPRINT9, e576f5c). But the only test of that leak,
* weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with
* hailBlockFor + hailAt it proves the primitive and the arithmetic, not the
* plumbing. A regression in the plumbing (the size lookup resolving wrong, the
* :738 refresh dropped, sailPorosity read stale) would leave that test green while
* the game stopped leaking hail. This drives the actual return, which is the whole
* reason balance.test is a browser suite: measure the real chain, never a copy.
*
* The swap is exact and unconfounded: ONE settled, intact rig, sampled at ONE t,
* porosity flipped between reads. `sky.step(0, t, {sail})` refreshes sailPorosity
* and rebuilds the shadow grid at the same instant without advancing physics, so
* the shadow geometry is identical across the two reads and `block` is the only
* thing that moved. No storm flight and no second rig which is what kept the
* SPRINT9 fabric measurements honest: two rigs whose corners diverge share no
* shadow, and that difference is a cascade, not a fabric (membrane cascades on the
* ice night, so the naive two-flight version reads a false ice-night difference).
*
* `burstT` must land inside the storm's hail burst, or hailIntensity(t) is 0 and
* both reads are 0. storm_03_southerly bursts at t=36 (pea, size 0.7);
* storm_02b_icenight at t=50 (ice, size 1.4).
*/
async function gardenHailByPorosity(yard, stormName, burstT) {
const def = await loadStorm(stormName);
const wind = createWind(def);
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
const calmWind = createWind(await loadStorm(CALM_STORM));
calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
if (!s) return null;
s.setFabric('cloth');
const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
// Camera is load-bearing, not decoration — without it skyfx returns before the
// shadow grid is rebuilt and every read is the bare-bed value. Same reason as fly().
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
camera.position.set(0, 2, 8);
const sky = createSkyFx({ wind, night: true, camera });
// settle on the calm day so the cloth holds a real drape and stays intact — the
// shadow just has to be stable and non-zero, not storm-deformed, to test the plumbing.
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % 3);
rig.porosity = 0.30; sky.step(0, burstT, { sail: rig });
const cloth = sky.gardenHailExposure(yard.bed, burstT);
rig.porosity = 0; sky.step(0, burstT, { sail: rig });
const membrane = sky.gardenHailExposure(yard.bed, burstT);
sky.dispose?.();
return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length };
}
/**
* @param {import('../testkit.js').Suite} t
*
@ -367,6 +429,13 @@ export default async function run(t) {
if (membraneShop) membraneShop.setFabric('membrane');
const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null;
// The fabric's OTHER half, and the one C wired the primitive for: porous cloth
// leaks pea hail into the garden score, membrane blocks it. Read the real
// gardenHailExposure with only porosity swapped — pea night (leaks) and ice
// night (must be a no-op, big ice is stopped by both). See the helper.
const peaHail = await gardenHailByPorosity(yard, 'storm_03_southerly', 38);
const iceHail = await gardenHailByPorosity(yard, 'storm_02b_icenight', 57);
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
@ -484,6 +553,41 @@ export default async function run(t) {
`(hp ${membrane.hp}) — the fabric is the decision`;
});
// The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it.
// `fabric decides p1` above proves porous saves the CORNER (less wind load); this
// proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises
// and C's hailBlockFor supplies. It drives sky.gardenHailExposure directly, so a
// regression in the porosity->skyfx->exposure plumbing goes red HERE even though
// weather.selftest's inline-formula version would stay green. (Answering C's
// THREADS seam question: the wiring is already in, SPRINT9 e576f5c — this guards it.)
t.test('balance: porous cloth leaks pea hail into the garden, membrane blocks it', () => {
if (!peaHail || !iceHail) throw new Error('could not build the fabric-hail probe on $80');
if (!(peaHail.cloth > 0 && peaHail.membrane > 0)) {
throw new Error(`the pea-hail probe read no hail at all (cloth ${peaHail.cloth}, membrane ` +
`${peaHail.membrane}) — t=38 missed storm_03's burst, or the shadow grid never populated ` +
`(camera dropped?). With nothing to block, this proves nothing.`);
}
// pea (size 0.7): porous leaks ~16% more through the real chain. Membrane blocks all.
if (!(peaHail.cloth > peaHail.membrane * 1.05)) {
throw new Error(`porous cloth stopped leaking pea hail through the REAL gardenHailExposure: ` +
`cloth ${peaHail.cloth.toFixed(3)} vs membrane ${peaHail.membrane.toFixed(3)} (want cloth > ` +
`membrane). The primitive test may still be green — this one drives sky.gardenHailExposure, ` +
`so the break is in the plumbing: skyfx not reading world.sail.porosity (:738), the size ` +
`lookup wind.def.hail.size resolving wrong, or hailBlockFor no longer folded into the return.`);
}
// ice (size 1.4): both stop it dead. hailBlockFor(1.4, 0.30) === hailBlockFor(1.4, 0) === 1.
// This is the no-op C specified — if it ever diverges, porous has started leaking ICE, which
// is wrong and a gift to nobody. Same intact rig both reads, so any gap is real, not a cascade.
if (Math.abs(iceHail.cloth - iceHail.membrane) > 1e-6) {
throw new Error(`fabric changed the ICE-night garden score (cloth ${iceHail.cloth.toFixed(4)} vs ` +
`membrane ${iceHail.membrane.toFixed(4)}) — porous is meant to leak only the finest hail, and ` +
`size 1.4 should read block=1 for both. hailBlockFor's aperture/smoothstep has drifted.`);
}
const leak = ((peaHail.cloth / peaHail.membrane) - 1) * 100;
return `real gardenHailExposure: porous leaks +${leak.toFixed(0)}% pea hail vs membrane; ` +
`ice night identical (both block big stones) — the fabric's garden cost is wired, not a copy`;
});
// The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the
// garden". Now that a clean win EXISTS, this stops being the wild night's only
// outcome and becomes what it should always have been — a different, worse bet

View File

@ -12,7 +12,10 @@
*/
import * as THREE from '../vendor/three.module.js';
import { YARD, createStubWind } from './contracts.js';
import { createStubWind } from './contracts.js';
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
/**
* Ground height in meters at a world XZ. Pure and cheap this is called by the
@ -33,32 +36,87 @@ export function heightAt(x, z) {
);
}
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
const SITE_DIR = new URL('../data/sites', import.meta.url).href;
// Shed on the east side, table out in front of it. Lane D tested reachability
// against (9, 6), so the table stays there and the shed tucks in behind.
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
/**
* Load a site. SPRINT10 gate 1 the yard stopped being code today.
*
* Everything that used to be a constant in this file (bed rect, house line,
* tree and post placements, shed, gnome, sun angle) is now `data/sites/*.json`,
* and world.js is a builder. The proof of a faithful extraction is that nothing
* moved: Lane E's anchor tripwires pin five branch positions to 6 dp and
* a.test's quad-band asserts pin the whole rigging geometry, so a byte-identical
* backyard_01 is checkable rather than claimable.
*
* Async and separate from createWorld() for the same reason dress() is: the
* selftest builds yards with no server, and a fetch in the constructor is either
* a break or a flake. Callers load the site, then build.
*
* @param {string} name e.g. 'backyard_01'
*/
export async function loadSite(name, dir = SITE_DIR) {
const url = `${dir}/${name}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`world: cannot load site ${url} (${res.status})`);
return validateSite(await res.json(), name);
}
// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia
// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 —
// the same line the graybox taught everyone to expect.
const HOUSE = { x: 0, z: -10.5 };
/**
* Sites are content, so they fail loud rather than half-build. Every field this
* checks is one that would otherwise produce a yard that looks fine and is
* subtly wrong an absent bed silently becomes NaN coverage, a missing anchor
* list silently becomes an unriggable site.
*/
export function validateSite(site, name = site?.id ?? '?') {
const bad = [];
if (!site || typeof site !== 'object') bad.push('not an object');
else {
if (!site.yard?.width || !site.yard?.depth) bad.push('yard.width/depth');
if (!site.gardenBed) bad.push('gardenBed');
if (!Number.isFinite(site.sun?.elevationDeg)) bad.push('sun.elevationDeg');
if (!Number.isFinite(site.sun?.azimuthDeg)) bad.push('sun.azimuthDeg');
const ids = [
...(site.house?.anchors ?? []).map((a) => a.id),
...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => a.id)),
...(site.posts ?? []).map((p) => p.id),
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => a.id)),
];
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
// tensioned from a cleat at its base and a tree strop is thrown, but a
// bracket bolted up a bare wall is the one you cannot fake — so that, and
// only that, wants the ladder. Keyed on mechanism because `type` is a closed
// enum ('house'|'tree'|'post') that site_02's carport doesn't fit, and
// needsLadder keyed on type FAILS OPEN there: the ladder mechanic silently
// vanishes and you re-rig a 2.6 m bracket standing on the grass (D's audit).
const works = [
...(site.house?.anchors ?? []),
...(site.trees ?? []).flatMap((t) => t.anchors ?? []),
...(site.posts ?? []),
...(site.structures ?? []).flatMap((s) => s.anchors ?? []),
];
for (const a of works) {
if (a.work !== 'cloth' && a.work !== 'bracket') {
bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
}
}
}
if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`);
return site;
}
// The gnome stands just off the bed's south-east corner — inside the footprint
// of most rigs, so a sail that lets go has something of the client's to land on.
// DESIGN.md: your own failure is the worst debris of all.
const GNOME = { x: 4.3, z: 4.4, rotY: -2.2, collateralValue: 25 };
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
const SUN_ELEV = (55 * Math.PI) / 180;
const SUN_AZIM = (-125 * Math.PI) / 180;
const SUN_DIR = new THREE.Vector3(
Math.cos(SUN_ELEV) * Math.sin(SUN_AZIM),
Math.sin(SUN_ELEV),
Math.cos(SUN_ELEV) * Math.cos(SUN_AZIM),
).normalize();
/** Direction from the GROUND toward the SUN (see contracts.js), off site data. */
function sunDirOf(site) {
const el = (site.sun.elevationDeg * Math.PI) / 180;
const az = (site.sun.azimuthDeg * Math.PI) / 180;
return new THREE.Vector3(
Math.cos(el) * Math.sin(az),
Math.sin(el),
Math.cos(el) * Math.cos(az),
).normalize();
}
const COLORS = {
sky: 0x9fc4dd,
@ -89,6 +147,26 @@ const COLORS = {
export function createWorld(scene, opts = {}) {
const wind = opts.wind ?? createStubWind({ calm: true });
// SPRINT10 gate 1. Every one of these was a module constant in this file an
// hour ago; they're site data now. They keep their old local names on purpose
// — the builder below is ~500 lines of geometry that was already correct, and
// rebinding the names is a change of SOURCE, not of behaviour. That's what
// makes "backyard_01 is byte-identical" a structural claim rather than a hope.
const site = validateSite(
opts.site ?? (() => { throw new Error(
'world: createWorld now needs a site — `createWorld(scene, { wind, site: await loadSite("backyard_01") })`. '
+ 'The yard stopped being code in SPRINT10; data/sites/*.json is the source of truth.'); })(),
);
const YARD = site.yard;
const GARDEN_BED = site.gardenBed;
const SUN_DIR = sunDirOf(site);
const HOUSE = site.house;
const SHED = site.shed;
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
const treeSpecs = site.trees ?? [];
const postSpecs = site.posts ?? [];
const root = new THREE.Group();
root.name = 'yard';
scene.add(root);
@ -194,18 +272,22 @@ export function createWorld(scene, opts = {}) {
solids.push(wall, roof);
graybox.house = house;
for (const [i, x] of [-5, 0, 5].entries()) {
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
// these onto the position Lane E baked, which is why the numbers here don't
// matter and the IDS do — the site names them, and everything downstream
// (Lane B's quads, D's prompts, the balance suite) refers to them by name.
for (const [i, a] of (HOUSE?.anchors ?? []).entries()) {
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
{ work: a.work },
));
}
// --- trees -------------------------------------------------------------
// Two, on opposite shoulders, mirroring the prototype's tree placement.
// Canopies are separate named nodes so they can be swayed independently —
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
const treeSpecs = [
{ id: 't1', x: -9, z: 2, phase: 0.7, trunkH: 4.2, anchorY: 3.4 },
{ id: 't2', x: 8, z: -2, phase: 2.9, trunkH: 3.8, anchorY: 3.1 },
];
// (treeSpecs is site.trees — bound at the top of createWorld.)
for (const spec of treeSpecs) {
const y0 = heightAt(spec.x, spec.z);
const tree = new THREE.Group();
@ -243,13 +325,14 @@ export function createWorld(scene, opts = {}) {
root.add(tree);
graybox.trees.set(spec.id, tree);
// The anchor is at a branch fork, not the canopy centre.
anchors.push(makeSwayAnchor(
spec.id,
new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z),
spec.phase,
wind,
));
// The anchor is at a branch fork, not the canopy centre. One per entry in
// the site's list; dress() moves each onto its baked branch_anchor_* node.
for (const a of spec.anchors ?? []) {
anchors.push(Object.assign(
makeSwayAnchor(a.id, new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z), spec.phase, wind),
{ work: a.work },
));
}
}
// --- posts -------------------------------------------------------------
@ -271,32 +354,10 @@ export function createWorld(scene, opts = {}) {
// be the expensive answer; the small quads buy survival and pay in patchy
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
// position is measured rather than guessed.
//
// The wild night had no winnable line because every anchor near the bed —
// p1/p2/p3 — is SOUTH of it, and nothing stands north short of the house 10 m
// away. So covering rigs had to span the yard and died, while rigs small
// enough to survive sat beside the bed instead of over it.
//
// p4 supplies the missing north-west corner. Swept its position against the
// smallest quad that covers 90% of the bed:
// (-2.2, -1.2) → 44.4 m² ← breaks the tradeoff (see below)
// (-3.2, -1.2) → 48.6 m² ← here
// (-3.2, -2.0) → 51.7 m²
// (-4.2, -3.0) → 60.0 m²
// beyond ~z=-4 → 63.5 m², i.e. too far away to matter at all
// Full coverage costs 63.5 m² without it. At (-3.2,-1.2) that drops to 48.6 —
// a real new option, 23% cheaper — while still costing you a bigger sail than
// the 23-38 m² rigs that survive on their own. Pulling it in to (-2.2,-1.2)
// would put full coverage at 44.4 m², INSIDE the survivable band, which
// collapses DESIGN.md's central tension: covering the bed has to cost risk.
// a.test.js asserts that >45 m² floor, so moving this post inward goes red.
const postSpecs = [
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
{ id: 'p3', x: 0, z: 7.0, h: 4.0 },
{ id: 'p4', x: -3.2, z: -1.2, h: 4.0 },
];
// position is measured rather than guessed — and the measurement moved with
// the data. p4's swept position table now lives in backyard_01.json's `_why`,
// beside the coordinate it justifies, because that is the number someone will
// be tempted to nudge. (postSpecs is site.posts, bound at the top.)
const RAKE = (8 * Math.PI) / 180;
for (const spec of postSpecs) {
const y0 = heightAt(spec.x, spec.z);
@ -330,7 +391,10 @@ export function createWorld(scene, opts = {}) {
footing.receiveShadow = true;
root.add(footing);
anchors.push(makeStaticAnchor(spec.id, 'post', top));
anchors.push(Object.assign(
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
{ work: spec.work },
));
}
// --- garden bed (the thing you are protecting) -------------------------
@ -370,11 +434,16 @@ export function createWorld(scene, opts = {}) {
fence.name = 'fence';
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
const hx = YARD.width / 2, hz = YARD.depth / 2;
const runs = [
{ from: [-hx, hz], to: [hx, hz] }, // south
{ from: [-hx, -hz], to: [-hx, hz] }, // west
{ from: [hx, -hz], to: [hx, hz] }, // east
];
const SIDES = {
south: { from: [-hx, hz], to: [hx, hz] },
west: { from: [-hx, -hz], to: [-hx, hz] },
east: { from: [hx, -hz], to: [hx, hz] },
north: { from: [-hx, -hz], to: [hx, -hz] },
};
// backyard_01 fences south/west/east — the house IS its north boundary. The
// corner block has a street on two sides and fences the other two, which is
// the whole reason this is a site field rather than three hardcoded runs.
const runs = (site.fence?.sides ?? ['south', 'west', 'east']).map((s) => SIDES[s]).filter(Boolean);
for (const run of runs) {
const [x0, z0] = run.from, [x1, z1] = run.to;
const len = Math.hypot(x1 - x0, z1 - z0);
@ -466,6 +535,7 @@ export function createWorld(scene, opts = {}) {
};
const load = async (name) => {
if (!name) return null; // the site simply doesn't have one
try {
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
gltf.scene.traverse((o) => {
@ -482,11 +552,13 @@ export function createWorld(scene, opts = {}) {
}
};
const [shed, table, houseGlb, tree1, tree2, bedGlb, gnome] = await Promise.all([
load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'),
load('tree_gum_01_v1'), load('tree_gum_02_v1'), load('garden_bed_v1'),
load('garden_gnome_01_v1'),
// Model names come off the site now. `load(null)` resolves null, so a site
// that simply has no shed (site_02 has a carport) skips it without a branch.
const [shed, table, houseGlb, bedGlb, gnome] = await Promise.all([
load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model),
load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model),
]);
const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));
// --- garden bed ------------------------------------------------------
if (bedGlb) {
@ -524,9 +596,7 @@ export function createWorld(scene, opts = {}) {
root.add(houseGlb);
solids.push(houseGlb);
houseGlb.updateWorldMatrix(true, true);
for (const [i, id] of ['h1', 'h2', 'h3'].entries()) {
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id);
}
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
}
// --- trees -----------------------------------------------------------
@ -534,7 +604,8 @@ export function createWorld(scene, opts = {}) {
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
// nothing that already references them breaks; the rest are added.
for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
for (const [i, spec] of treeSpecs.entries()) {
const glb = treeGlbs[i];
if (!glb) continue;
const old = graybox.trees.get(spec.id);
retire(old);
@ -552,20 +623,13 @@ export function createWorld(scene, opts = {}) {
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
}
// Every branch anchor the site names now EXISTS before dress() runs (they
// were created off spec.anchors above), so this only has to move them onto
// the nodes Lane E baked. That deleted the old suffix-guessing loop, which
// built t1/t1b/t1c out of an array index and could only ever describe the
// two trees it was written for.
glb.updateWorldMatrix(true, true);
const suffix = ['', 'b', 'c'];
for (let i = 1; i <= 3; i++) {
const node = glb.getObjectByName(`branch_anchor_0${i}`);
if (!node) continue;
const id = spec.id + suffix[i - 1];
if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id);
else {
const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld);
const a = makeSwayAnchor(id, p, spec.phase, wind);
a.ratingHint = node.userData?.rating_hint ?? 1;
anchors.push(a);
}
}
for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
}
if (shed) {