diff --git a/tools/site_audit/gardenfly.selftest.js b/tools/site_audit/gardenfly.selftest.js index 92d62cc..f2b8ff3 100644 --- a/tools/site_audit/gardenfly.selftest.js +++ b/tools/site_audit/gardenfly.selftest.js @@ -119,7 +119,12 @@ export async function buildGardenflyTests() { let sepStates = null, sepStatesErr = null; try { sepStates = []; - for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) { + // SPRINT17 gate 3.2 [D]: site_04_pool_yard added to the judged walk in the + // same commit that ships it (B's file, flagged in THREADS, revert-and-tell- + // me standard) — a yard missing from this list is UNJUDGED silently, which + // is the exact silence the gate-2.3 rule forbids. It carries a refusal + // finding (bare 83.7 FULL under its southerly; receipts in the site file). + for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', 'site_04_pool_yard']) { const sd = await loadSite(name); sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding }); } @@ -204,7 +209,7 @@ export async function buildGardenflyTests() { // but silence is not an option, and holding both means the finding went // stale the day the pin landed and must be deleted (its own text says so). if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`); - assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`); + assert(sepStates.length === 4, `expected 4 shipped yards, read ${sepStates.length}`); for (const s of sepStates) { assert(s.pinned || s.finding, `${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` + diff --git a/web/world/data/sites/site_04_pool_yard.json b/web/world/data/sites/site_04_pool_yard.json new file mode 100644 index 0000000..ebc1c40 --- /dev/null +++ b/web/world/data/sites/site_04_pool_yard.json @@ -0,0 +1,237 @@ +{ + "id": "site_04_pool_yard", + "name": "The Pool Yard", + "blurb": "Fourteen perfect fence posts and not one of them is yours to use. The only steel standing where the shade wants a corner went in before the pool did.", + "_design": [ + "THE YARD WHERE EVERYTHING SAYS NO EXCEPT THE THING THAT SHOULD NOT.", + "ROADMAP arc-2 thesis: fence compliance + corroded cheap hardware. The pool ring is", + "FOURTEEN honest noes (every post tie_off:false in the GLB — a certified pool barrier", + "is not an anchor, at any rating, ever); the corroded post c1 is the one thing that", + "DOES adopt on the east side, at 0.55, and it stands exactly where the bed's NE", + "corner wants steel. That is not an accident, it is the whole level:", + " - the bed is poolside; every 92%-cover quad in the band routes through c1.", + " Full shade in this yard has rust in it, or it does not exist (measured, SCORE IT:", + " the three 92% lines are t1,p1,p2,c1 / t1,t1b,p2,c1 / t1b,p1,p2,c1 — no others).", + " - the honest steel (gum t1/t1b, posts p1,p2,p3, the familiar 0.35 fascia) tops out", + " at 58% cover for $40 — the cheap safe rig leaves the bed's pool corner open.", + " - c1 is SHAPE-SENSITIVE, and that is the corroded tier working: the same drooped", + " eye is clean at $65 rated on t1,p1,p2,c1, and UNHOLDABLE AT ANY PRICE on the two", + " quads that pull it harder (t1,t1b,p2,c1 and t1b,p1,p2,c1). You cannot read which", + " off the object — E built the rung so you gamble rather than compute (their 0.55", + " reasoning, THREADS S17), and this yard is that gamble with a fence around it.", + "WIND: the venturi is the channel between the house's east end and the pool enclosure", + "— real geometry, gate-side, running N-S (axis 1.571, gain 1.4, r 5, throat (3.1,-3.5)).", + "It bites c1 and h3: the two lie-tiers are the two corners in the wind. The wreck", + "apron is PAID FOR (E's placement facts): c1 falls +Z ~3.9 m and the shaft lands in", + "the path between bed (x<=2.5) and ring (x>=3.7) — clear of both by ~0.3 m each side.", + "One corroded post ON PURPOSE: two would share the baked collateral key", + "(structFor/wreckStructure match the first entry; C's envelope dedupes keys), so the", + "second failure would bill and wreck the wrong post. Filed in THREADS [D] S17 —", + "a second rust rung in one yard needs its own key plumbing first." + ], + "_why": [ + "Authored COLD in the yard editor, SPRINT17 gate 3.2 — palette shapes, editor", + "placement, iterative SCORE IT (three passes: 2 quads -> tightened to band -> 13).", + "Every number below is the SCORE IT card, storm_03_southerly, FUNNEL ON, 9 dressed", + "anchors, export-clone world, 136.5 s flight. Confirmed by audit.html over the saved", + "file (same engine, second route) before shipping.", + " WINNABLE at $80 — 13 quads in band, 6 clean, 1 marginal.", + " cheapest honest line h1,t1b,p2,p3 — $40 hardware (+$15 spare), 45 m2, 58% cover,", + " garden 92.7 FULL, +9.0 HP over bare. No rust in it.", + " the rust gate full cover exists ONLY through c1 (92% x3, above); cheapest", + " clean rust line t1,p1,p2,c1 $65 — corrosion prices as +$25 of", + " hardware you must rate UP, plus a $45 stake on a 0.55 eye.", + " margin flags c1 4% headroom on h1,h2,p2,c1 (wrecks the corroded post $45);", + " p1 2% / t1b 2% / t1 4% — the 15% rule, marginal is not PASS.", + " collateral exposure gutter $90 (h1-h3) + corroded post $45 (c1) = $135 from", + " anchors; board exposure $160 with the gnome (exposureOf).", + " bare bed 83.7 FULL — the southerly cannot kill an open bed (its pea", + " hail takes 10.7 HP). The sail here is for the STEEL and the", + " collateral, per A's S17 gate-0.4 ruling — which is this", + " yard's thesis said as canon." + ], + "yard": { + "width": 24, + "depth": 16 + }, + "sun": { + "elevationDeg": 55, + "azimuthDeg": -125 + }, + "gardenBed": { + "x": 0, + "z": 1.5, + "w": 5, + "d": 3.5 + }, + "house": { + "model": "house_yardside_v1", + "wreckedModel": "house_yardside_wrecked_v1", + "collateralKey": "gutter", + "collateralValue": 90, + "collateralLabel": "the gutter", + "x": -3, + "z": -6, + "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" + } + ] + }, + "structures": [ + { + "id": "c1", + "model": "sail_post_corroded_v1", + "wreckedModel": "sail_post_corroded_wrecked_v1", + "x": 3.1, + "z": -2, + "rotYDeg": 0, + "solid": true, + "collateralKey": "corroded_post", + "collateralValue": 45, + "collateralLabel": "the corroded post", + "anchors": [ + { + "id": "c1_a1", + "node": "top_anchor", + "type": "corroded_post", + "work": "cloth" + } + ] + }, + { + "id": "pool", + "model": "pool_kit_01_v1", + "x": 6.5, + "z": 1.5, + "rotYDeg": 0, + "solid": true, + "anchors": [] + } + ], + "trees": [ + { + "id": "t1", + "model": "tree_gum_02_v1", + "x": -6.8, + "z": 2, + "phase": 1.7, + "trunkH": 3.8, + "anchorY": 3.1, + "anchors": [ + { + "id": "t1", + "node": "branch_anchor_01", + "type": "tree", + "work": "cloth" + }, + { + "id": "t1b", + "node": "branch_anchor_02", + "type": "tree", + "work": "cloth" + } + ] + } + ], + "posts": [ + { + "id": "p1", + "x": -3.4, + "z": 4.6, + "h": 4, + "type": "post", + "work": "cloth" + }, + { + "id": "p2", + "x": 3.4, + "z": 4.6, + "h": 4, + "type": "post", + "work": "cloth" + }, + { + "id": "p3", + "x": -5.8, + "z": -1.5, + "h": 4, + "type": "post", + "work": "cloth" + } + ], + "fence": { + "sides": [ + "north", + "south", + "east", + "west" + ] + }, + "shed": { + "model": "shed_01_v1", + "x": -9.5, + "z": -5.5, + "rotYDeg": -90 + }, + "shedTable": { + "model": "shed_table_v1", + "x": -6.5, + "z": 7, + "rotYDeg": -90, + "pickupNode": "pickup_anchor" + }, + "gnome": { + "model": "garden_gnome_01_v1", + "x": 4.6, + "z": 4.4, + "rotYDeg": 0, + "collateralValue": 25 + }, + "wind": { + "_venturi": [ + "The pool-terrace channel: house east end to pool enclosure, gate side, running N-S.", + "Axis 1.571 is the channel's GEOMETRY (a line, mod pi — site_02's reconciled lesson),", + "not a storm heading. Gain 1.4 / r 5 puts c1 (1.4 m off-throat) and h3 (3.7 m) in the", + "wind: the corroded post is the venturi post, which is the temptation priced." + ], + "venturi": [ + { + "x": 3.1, + "z": -3.5, + "axis": 1.571, + "gain": 1.4, + "radius": 5, + "sharp": 3 + } + ] + }, + "_separation_finding": [ + "SPRINT17 gate 3.2 [D] — a separation pin was ATTEMPTED and honestly REFUSED, the", + "third refusal in the family (site_02, site_03, now here) and the first authored", + "AFTER A's gate-0.4 ruling made the pattern canon. Measured (SCORE IT, funnel ON,", + "storm_03_southerly, 2026-07-21): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP);", + "best flown 92.7 FULL (h1,t1b,p2,p3, $40); best gain +9.0 HP. bareMustLoseBelow", + "cannot be written at any threshold — bare WINS the night by 33.7 over the win", + "line. On this night the sail is for the steel and the collateral (A, S17 0.4,", + "ruled canon, taught by the board's exposure line): what c1 separates is not the", + "bed from the hail but $45 and your margin from your greed. When B's garden-BONUS", + "separation shape lands (A ruled FOR it, deferred to its own gate), re-measure —", + "this yard's +9.0 over bare at $40 is a bonus-stake candidate, not a win/lose one." + ] +} diff --git a/web/world/js/board.js b/web/world/js/board.js index 78661c2..8e2efa2 100644 --- a/web/world/js/board.js +++ b/web/world/js/board.js @@ -240,6 +240,29 @@ export const POOL = [ + 'working, it stops you buying your way out of choosing. The carport at $180 is the ' + 'tension — the rig this cap pushes you toward is the one with the least margin over it.', }, + { + // SPRINT17 gate 3.2 [D] — THE POOL YARD. The first yard whose ONLY route + // into the game is being chosen: it is in no NIGHTS slot, so declining it + // forever is a complete campaign. That is the arc-2 promise made literal, + // and it is why the entry lives here and not in week.js. + id: 'pool_yard_southerly', + storm: 'storm_03_southerly', site: 'site_04_pool_yard', + client: 'the Karalis place', addr: '5 Clearwater Ct — the pool yard', + brief: 'New pool, new fence, certified in March — and the certifier is back Thursday, so ' + + 'nothing touches that fence. Not a rope, not a clamp. The old sail post from before ' + + 'the pool went in is still standing, they say, and they say it like a selling point. ' + + 'It has been breathing pool air for years. Tuesday\'s southerly, the bed\'s by the water.', + _why: 'AUTHORED COLD + AUDITED (D, S17 gate 3.2 — SCORE IT card, funnel ON, confirmed by ' + + 'audit.html over the shipped file): 13 quads in band, 6 clean, 1 marginal; cheapest ' + + 'hold $40 (h1,t1b,p2,p3 → garden 92.7 FULL, 58% cover, no rust in it). The trap is ' + + 'priced, not padded: every 92%-cover line routes through the corroded post c1 (0.55, ' + + '$45 collateral, IN the venturi) — clean at $65 rated on t1,p1,p2,c1, UNHOLDABLE on ' + + 'the two shapes that pull the eye harder. Southerly = the storm nights 2 and 4 teach; ' + + 'the yard is the only new variable (one-variable law). Separation REFUSED with ' + + 'receipts in the site file (bare 83.7 FULL — mild-night canon per A\'s 0.4 ruling); ' + + 'exposure $160 (gutter 90 + corroded post 45 + gnome 25), the fence\'s fourteen ' + + 'posts deliberately absent from that number because none of them can be tied to.', + }, ]; /** diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index ac55972..bb6af42 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -24,6 +24,13 @@ import { loadStorm, createWind } from '../weather.js'; // file read the ladder. week.js is contracts-only underneath, so this stays // headless-safe. import { NIGHTS, nightAt } from '../week.js'; +// SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL +// (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a +// site file this suite merely fetch()ed raw could rot invalid and stay green). +// loadSite pulls world.js which pulls THREE — browser-only, same as the GLB +// facts everywhere else in this repo; the node runner never imports this file. +import { POOL, exposureOf } from '../board.js'; +import { loadSite } from '../world.js'; const DT = FIXED_DT; @@ -1229,4 +1236,96 @@ export default async function run(t) { "the wildnight sits directly before the icenight — the icenight's brief says 'less wind than " + "last night', and the paper never lies: re-order these and re-word that brief in the same commit"); }); + + // ── SPRINT17 gate 3.2: THE POOL YARD — authored cold, entered through the ── + // board, pinned by its author. These pins carry the yard's three claims: + // it is CHOSEN or absent (never scripted), its fence is fourteen honest noes + // and its rust is one priced yes, and its refusal + wreck apron are facts + // with numbers, not vibes. The site file itself goes through loadSite here, + // so a hand-edit that breaks validateSite goes red in THIS suite too, not + // only at boot. + let poolSite = null, poolSiteErr = null; + try { poolSite = await loadSite('site_04_pool_yard'); } + catch (err) { poolSiteErr = String((err && err.stack) || err); } + + t.test('pool yard (S17 3.2): in the game ONLY through the board — chosen, never scripted', () => { + const entry = POOL.find((p) => p.site === 'site_04_pool_yard'); + assert(entry, 'the pool yard has a POOL entry — its one door into the game'); + assertEq(entry.storm, 'storm_03_southerly', + 'it flies the southerly — the storm nights 2 and 4 teach, so the yard is the only new variable; ' + + 'any other pairing needs its own gauntlet run FIRST (the _why receipt describes this one)'); + assert(entry._why && entry._why.includes('audit'), + 'the entry carries its audit receipt — an unmeasured pairing is a night the board is guessing about'); + const scripted = NIGHTS.map((_, i) => nightAt(i)); + assert(!scripted.some((n) => n.site === 'site_04_pool_yard'), + 'NO scripted night flies the pool yard — "the first yard whose only route in is being chosen" ' + + 'is the arc-2 promise, and a week.js slot would quietly break it'); + }); + + t.test('pool yard (S17 3.2): the ring says no fourteen times, the rust says yes once', () => { + if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); + const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1'); + assert(ring, 'the pool ring is in the yard — it IS the thesis'); + assertEq((ring.anchors ?? []).length, 0, + 'the ring adopts NOTHING in site data — its fourteen noes are baked tie_off:false in the GLB, ' + + 'and a site-level anchor here would override a certified fence into steel'); + assert(ring.collateralValue == null, + 'the ring is unpriced BY RULING (the bike rule) — nothing can bend a pool fence yet'); + const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1'); + assert(rust, 'the corroded post stands'); + assertEq((rust.anchors ?? []).length, 1, 'exactly one adoptable eye'); + assertEq(rust.anchors[0].type, 'corroded_post', 'typed to E\'s tier so it rates 0.55, not 1.00'); + assertEq(rust.collateralValue, 45, 'the make-safe bill, E\'s number, adopted with its reasoning'); + assert(rust.wreckedModel === 'sail_post_corroded_wrecked_v1', + 'the wreck variant is wired — a trap that vanishes instead of falling is a free failure'); + const rusts = poolSite.structures.filter((s) => s.model === 'sail_post_corroded_v1'); + assertEq(rusts.length, 1, + 'ONE corroded post on purpose: two would share the baked collateral key and the second failure ' + + 'would bill/wreck the wrong one (structFor matches first; filed in THREADS [D] S17)'); + }); + + t.test('pool yard (S17 3.2): separation-JUDGED — refused with receipts, XOR the pin', () => { + if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); + const pinned = !!poolSite.separation, finding = !!poolSite._separation_finding; + assert(pinned || finding, 'the yard is JUDGED — silence is not an option (S16 gate 2.3)'); + assert(!(pinned && finding), 'both at once means the finding went stale in the pinning commit'); + assert(finding, 'today it is a REFUSAL: bare wins the southerly (mild-night canon, A\'s 0.4 ruling)'); + const text = poolSite._separation_finding.join(' '); + assert(text.includes('83.7') && text.includes('92.7'), + 'the refusal carries its measured receipts (bare 83.7 / best flown 92.7) — a finding without ' + + 'numbers is a shrug wearing a ruling\'s clothes'); + }); + + t.test('pool yard (S17 3.2): the wreck apron threads the needle between bed and ring', () => { + if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); + // E's placement facts (THREADS S17 gate 3.1): the corroded post falls +Z + // ~3.9 m, shaft half-width ~0.26 m. The fallen shaft must land in the path + // — clear of the bed's east edge AND the ring's west run — or the wreck + // interpenetrates client property the moment the trap fires. + const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1'); + const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1'); + const bedEast = poolSite.gardenBed.x + poolSite.gardenBed.w / 2; + const shaftWest = rust.x - 0.26, shaftEast = rust.x + 0.26; + const ringWest = ring.x - 2.8; // ring proper 5.6 wide (E's measured bbox) + assert(bedEast <= shaftWest, + `bed east edge ${bedEast} overlaps the fallen shaft (west ${shaftWest.toFixed(2)}) — move c1 or the bed`); + assert(shaftEast <= ringWest, + `fallen shaft east ${shaftEast.toFixed(2)} reaches the ring (west ${ringWest.toFixed(2)}) — move c1 or the pool`); + const apronTip = rust.z + 3.9; + assert(apronTip <= poolSite.yard.depth / 2, + `the shaft falls past the south fence (tip z ${apronTip}) — it must land IN the yard, that is the point`); + }); + + t.test('pool yard (S17 3.2): the offer card prices the rust and never the fence', () => { + if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`); + const { total, items } = exposureOf(poolSite); + assertEq(total, 160, 'exposure $160 = gutter 90 + corroded post 45 + gnome 25'); + const labels = items.map((i) => i.what).sort().join(' · '); + assertEq(labels, 'garden gnome · the corroded post · the gutter', + 'three priced items exactly — the fourteen fence posts are deliberately NOT exposure: ' + + 'nothing can bend a pool fence yet (the bike rule), and a $0 line item would teach "free"'); + const rustItem = items.find((i) => i.what === 'the corroded post'); + assertEq(rustItem.cost, 45, + 'the rust is the cheapest structure you can break — the stake E sized the gamble in'); + }); }