diff --git a/THREADS.md b/THREADS.md index 7619718..17a72eb 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4009,3 +4009,32 @@ anchors are your GLB), but the tooling is now waiting, not TODO. arithmetic, neg class) — because both of last sprint's card bugs were invisible to a suite that never rendered a card. hud.dispose() also stops leaking the dawn div. selftest **317/0/0** (315 + the two card renders). + +[A] 2026-07-17 — 🚲 **The bike is WIRED (SPRINT12 gate 3.2), and the $60 is ruled: DECLINED, for E's own + reason.** site-JSON key + loader line, the gnome pattern exactly — `bike` is a named top-level key + in backyard_01, world.js loads it in the same Promise.all and drops it at `heightAt`. Not in + solids, not billed. + + **Placement is measured, not reasoned** (the numbers live in the site JSON's `_why`): I parsed the + GLB in node before placing anything — the bars' grip reaches 0.286 m past the tyre-contact line + toward the baked lean, so at rotYDeg 180 (local −Z → global +Z, per E's exporter note, read FIRST + as instructed) and z = 9.69, the grip lands on the south fence rail's inner face at 9.98 and the + tyres stand 0.31 m clear. x = −7 sits between fence posts so bars meet rail, not post. Verified by + LOOKING: walked the player over in the browser — it reads as a kid's bike leaning on the palings, + step-through diagonal doing the work E said it would. New a.test pins the placement side of E's + axis trap: rotYDeg must point the baked lean at the fence (dot > 0.95) and the standoff must stay + in the bars-touch band — drag it mid-yard or flip the rotation and the suite reddens with the + right sentence. + + **RULING — no collateralValue, and an assert enforces the reasoning.** E's framing decides it: + "a bike that ignores that gust is a worse lie than no bike." Priced, the lie gets WORSE — the sim + cannot knock a bike over, so the $60 would bill for an event the player never sees, which is the + exact species of unearned consequence the invoice exists to kill (the carport bills because the + roof visibly leaves). The placement is the mitigation: leaning on the WINDWARD fence is the one + pose a southerly can't falsify — the gust pins it harder into the palings, so standing still is + physically honest in every storm the backyard hosts. The tripwire assert fails if anyone prices + the bike without building the fall, and says so: build the fall first, then bring the $60 back — + it's the right number (under the gnome-to-carport span, and the one loss the client's kid would + notice). + + selftest **318/0/0** (317 + the placement pin). Landing pushed. diff --git a/web/world/data/sites/backyard_01.json b/web/world/data/sites/backyard_01.json index a084960..a13a393 100644 --- a/web/world/data/sites/backyard_01.json +++ b/web/world/data/sites/backyard_01.json @@ -87,6 +87,24 @@ "gnome": { "model": "garden_gnome_01_v1", "x": 4.3, "z": 4.4, "rotYDeg": -126, "collateralValue": 25 }, + "bike": { + "model": "bike_kid_01_v1", "x": -7, "z": 9.69, "rotYDeg": 180, + "_why": [ + "The Hendersons' kid's bike, against the south fence. THE LEAN IS BAKED INTO THE GLB and it", + "leans toward local -Z (E's THREADS note; e.test pins it) - a bike doesn't stand up on its own.", + "rotYDeg 180 turns that lean into global +Z, INTO the fence. Any placement that doesn't point", + "the lean at something to lean ON reads as a physics bug and isn't one.", + "z is MEASURED, not reasoned: the bars' grip reaches 0.286 m past the origin (the tyre contact", + "line) toward the lean, and the fence rails sit at z = 10 +/- 0.02, so 9.69 + 0.286 = 9.98 =", + "the rail's inner face - bars touch the fence, tyres stand 0.31 m clear. x = -7 sits between", + "fence posts (-7.5 and -5), so the grip meets rail, not post.", + "NO collateralValue, BY RULING (THREADS [A] sprint 12): the sim cannot knock a bike over, and", + "billing $60 for an event the player never sees is the kind of lie the invoice exists to kill.", + "Leaning on the windward fence is the one pose a southerly can't falsify - the gust pins it", + "harder. Price it the day it can fall." + ] + }, + "fence": { "sides": ["south", "west", "east"] }, diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 90dd8ab..aa55099 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -342,6 +342,38 @@ export default async function run(t) { } finally { hud.dispose(); } }); + t.test("SPRINT12: the kid's bike leans INTO the south fence, not into open air", () => { + // E's axis trap, pinned from the PLACEMENT side. e.test proves the GLB + // leans toward local −Z; nothing until now proved the site points that lean + // at anything. Un-rotate the bike or drag it mid-yard and this reddens — + // the failure mode is a bike leaning on air, which reads as a physics bug + // and would get chased in the wrong lane (E's words, their lean note). + const b = site.bike; + assert(b, 'backyard_01 places the bike'); + assertEq(b.model, 'bike_kid_01_v1', "E's model, the v1 with the baked lean"); + + // Local −Z rotated by rotY must land on +Z — the south fence side. + const ry = (b.rotYDeg * Math.PI) / 180; + const leanTowardFence = -Math.cos(ry); // z of (0,0,−1) rotated about Y + assert(leanTowardFence > 0.95, + `rotYDeg ${b.rotYDeg} points the baked lean at z=${leanTowardFence.toFixed(2)} — not the fence`); + + // The standoff, measured: bars reach 0.286 m past the tyre line toward the + // lean; the fence rails sit at z = depth/2 ± 0.02. The band accepts + // bars-on-rail ± a hand's width, and rejects mid-yard or tyres-in-fence. + const gap = site.yard.depth / 2 - b.z; + assert(gap > 0.15 && gap < 0.45, + `bike stands ${gap.toFixed(2)} m off the fence line — bars touch at ~0.31`); + + // RULING (SPRINT12 gate 3.2): E's $60 is DECLINED until the sim can knock a + // bike over. Billing collateral for an event the player never sees is the + // lie the invoice exists to kill; leaning on the windward fence is the one + // pose a southerly can't falsify. This assert is the ruling's tripwire: if + // someone prices the bike, they must also build the fall, and this message + // is where they find that out. + assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first'); + }); + // --- SPRINT11 gate 1: the three rulings, each pinned ---------------------- t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => { diff --git a/web/world/js/world.js b/web/world/js/world.js index 0ad2032..b8a98bf 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -200,6 +200,9 @@ export function createWorld(scene, opts = {}) { 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; + // SPRINT12: the per-client prop, the gnome pattern — a named top-level key, + // not a generic props[]. See the site JSON's _why for the lean/fence math. + const BIKE = site.bike ? { ...site.bike, rotY: rad(site.bike.rotYDeg) } : null; const treeSpecs = site.trees ?? []; const postSpecs = site.posts ?? []; @@ -614,9 +617,10 @@ export function createWorld(scene, opts = {}) { // 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([ + const [shed, table, houseGlb, bedGlb, gnome, bike] = await Promise.all([ load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model), load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model), + load(BIKE?.model), ]); const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model))); @@ -642,6 +646,20 @@ export function createWorld(scene, opts = {}) { root.add(gnome); } + // --- the kid's bike (per-client juice, SPRINT12) ---------------------- + // The lean is baked into the GLB toward local −Z (E's export note — in + // Blender it leans +Y; the exporter maps (x,y,z)→(x,z,−y)), so placement's + // ONE job is to point that lean at the fence. rotY comes from site data; + // the site's _why records the measured bars-to-rail arithmetic. Not in + // solids — you can walk past a kid's bike — and not billed: unpriced by + // ruling until wind can knock it over. + if (bike) { + bike.name = 'bike_kid_01'; + bike.position.set(BIKE.x, heightAt(BIKE.x, BIKE.z), BIKE.z); + bike.rotation.y = BIKE.rotY; + root.add(bike); + } + // --- house (decision 6: no re-cut, the GLB's data wins) --------------- // E's fascia sits at 2.80 m and their anchors span x=-3..3, where my // graybox guessed 2.6 m and -5..5. Reading them narrows the house span by