From c182fc14cbfc3ceb07408a1f6632e9dfd366007e Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sun, 19 Jul 2026 11:11:05 +1000 Subject: [PATCH] =?UTF-8?q?editor:=20the=20honest=20edges=20=E2=80=94=20En?= =?UTF-8?q?ter=20commits,=20bounds=20are=20seen,=20keyboard=20zoom,=20sepa?= =?UTF-8?q?ration=20ruled=20(S15=20gate=203=20+=20gate=201.1=20ruling)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 1.1 RULED in THREADS: the honest post is 1.00 — the shipped value becomes canon, E re-bakes to match, and the adoption mechanism must make a baked hint reach JSON-declared anchors (same integration, no 0.90 window). B's re-measure becomes a zero-delta verification. Gate 3: numInput/textInput commit on Enter (idempotent — a no-op commit pays no rebuild); boundsFaults() rides the validation cache so header, panel and export read one measurement, export leads with _OUT_OF_BOUNDS (not _INVALID — that key's sentence would lie); the yard rectangle draws as an amber line where fence-off sides hid it; +/- zoom on the wheel's own curve; separation ruled a measured pin with the SITE panel saying so. New a.test mutation-checked both ways in the browser harness. 410/0/0. Co-Authored-By: Claude Fable 5 --- THREADS.md | 54 +++++++++++++ web/world/js/editor.js | 144 +++++++++++++++++++++++++++++++---- web/world/js/tests/a.test.js | 28 ++++++- 3 files changed, 210 insertions(+), 16 deletions(-) diff --git a/THREADS.md b/THREADS.md index ac6044f..df70d82 100644 --- a/THREADS.md +++ b/THREADS.md @@ -6849,3 +6849,57 @@ anchors are your GLB), but the tooling is now waiting, not TODO. finding touched DIFFERENT halves of debris/editor and merged clean; the palette fix means the swing set now DRAWS, which makes E's S14 negative-control discipline the next thing the editor exercises. + +[A] 2026-07-18 — ⚖️ **GATE 1.1 RULED + GATE 3 LANDED (run by the integrator wearing Lane A's + hat, at John's ask — one lane, worked to the lane's own standard).** + + **THE RULING — the honest post is 1.00. The shipped value becomes canon.** + · Every measured number in this repo was measured against posts at effective 1.00: the S13 + separation pins, the winnable-lines tables, night 5's few-hundredths whisker (3.20 kN shop + cap vs 3.22/3.25 peaks — "kept on purpose"), and every played verdict D ever filed. A 0.90 + nobody has ever played is design intent that never shipped; making it real now would move + ALL of those numbers to serve a value with zero hours of play behind it. "The value we + shipped and balanced against is the real one" — the gate's own words, taken. + · If a weaker honest post is WANTED later, that is a design change proposed with evidence + (B's deltas, D's feel), wearing its own name — not a bugfix. + · **The mechanism half stands in full and E's re-bake is REQUIRED:** sail_post's + `top_anchor` re-bakes 0.90 → 1.00 so the factory agrees with canon, and the adoption + mechanism must make a baked hint REACH a JSON-declared anchor (with E's gate-1.2 assert) + so the next asset cannot silently diverge — `tie_off`, `collateral`, all of that family. + **Land the re-bake and the mechanism in the SAME integration** — no window where posts + read 0.90. B's gate-1.3 re-measure then becomes a verification that nothing moved + (expected: zero deltas), which is the cheapest possible version of that gate. + + **GATE 3 — the honest edges, landed on main.** Selftest **410/0/0** (409 + 1), the new + assert mutation-checked BOTH ways in the browser harness (bed-edges check off → red "got 0, + want 1"; _OUT_OF_BOUNDS key off → red on its own assert; two separate mutation runs so + neither hides behind the other): + · **3.1 Enter commits** — numInput and textInput commit on Enter AND change, idempotent (a + no-op commit does not pay a rebuild). Verified live: yard w 24 → 30 by Enter alone, no + blur, world rebuilt. I did not root-cause why 'change' alone failed D in anger — the + explicit keydown path makes the question moot, and I'd rather say that than guess. + · **3.2 Out of bounds is SEEN, three surfaces, one measurement** — `boundsFaults(site)` + (exported, a.test-covered) rides the validation cache so the header + ("valid · 1 OUTSIDE BOUNDS"), the VALIDATION panel list, and the export all read the same + numbers. Export leads with a loud `_OUT_OF_BOUNDS` key — deliberately NOT `_INVALID`, + because that key's sentence ("the game cannot boot it") would sometimes lie; this yard + boots, it's just probably not what you meant. Centre-point check by design (footprint + bounds need per-model dims the palette doesn't carry) EXCEPT the garden bed, whose w/d we + know — its edges count. On-the-line is legal: snapping to the fence is not a mistake. + Plus the yard rectangle drawn as an amber ankle-height line, rebuilt with the handles — + bounds were invisible on fence-off sides, which is exactly where D placed past them. + · **3.3 Keyboard zoom** — +/− (and =/_), same Exp curve as the wheel so the two agree. + Verified live: 28.19 → 25.07 → 28.19. + · **3.4 separation RULED: it is a measured pin, not an authored wish.** No editor field, on + purpose — a hand-typed separation block is a wish wearing a pin's clothes. The SITE panel + says so where an author would look for the field, and points at SCORE IT + the MANUAL §5 + integration step. **B:** if you want SCORE IT's card to grow a "copy measured separation + block" affordance, that's yours and I'd take it — the pin still lands at integration. + · **3.5 tie_off at the palette layer: NOTHING TO BUILD, recorded so the gate can close.** + The palette places whole objects; anchors come from GLB adoption at dress time, where + `tie_off:false` is already honoured (E's runtime half). The editor never offers naked + node anchors, so there is no palette surface for the flag to protect. If the palette ever + grows per-node anchor offers, this entry is the tripwire. + + 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). diff --git a/web/world/js/editor.js b/web/world/js/editor.js index 6948f69..b37b0e8 100644 --- a/web/world/js/editor.js +++ b/web/world/js/editor.js @@ -155,17 +155,62 @@ function canonical(value, shape = '$root') { */ export function exportSiteJSON(site, validation) { const body = canonical(site, '$root'); - const out = (validation && !validation.ok) - ? { _INVALID: [ - 'THIS YARD DOES NOT VALIDATE — the game cannot boot it. Exported so you', - 'do not lose the work. Fix these and re-export before it goes anywhere', - 'near web/world/data/sites/:', - ...validation.errors, - ], ...body } - : body; + let out = body; + // Bounds faults are the SECOND loud key (SPRINT15 gate 3.2): the game will + // boot this yard — validateSite is structural — but an object outside the + // fence is almost never what the author meant, and "the yard still reads + // VALID" was exactly D's complaint. Not folded into _INVALID because that + // key's sentence ("the game cannot boot it") would then sometimes lie. + const bounds = validation?.bounds ?? []; + if (bounds.length) { + out = { _OUT_OF_BOUNDS: [ + 'These sit outside the yard rectangle. The game boots this, but check it', + 'is what you meant before it goes near web/world/data/sites/:', + ...bounds, + ], ...out }; + } + if (validation && !validation.ok) { + out = { _INVALID: [ + 'THIS YARD DOES NOT VALIDATE — the game cannot boot it. Exported so you', + 'do not lose the work. Fix these and re-export before it goes anywhere', + 'near web/world/data/sites/:', + ...validation.errors, + ], ...out }; + } return `${JSON.stringify(out, null, 2)}\n`; } +/** + * Everything placed outside the yard rectangle, as human sentences. + * Centre-point check on purpose: footprint-aware bounds need per-model dims + * the palette doesn't carry, and D's gate-3 repro (a placement metres past + * the fence, silent) is a centre-point miss. The garden bed is the exception + * — it's an axis-aligned rect whose w/d we DO know, so its edges count. + * Exported for a.test. [SPRINT15 gate 3.2] + */ +export function boundsFaults(site) { + const w = site.yard?.width, d = site.yard?.depth; + if (!(w > 0) || !(d > 0)) return []; + const hw = w / 2, hd = d / 2; + const out = []; + const check = (label, x, z, exX = 0, exZ = 0) => { + if (!Number.isFinite(x) || !Number.isFinite(z)) return; + if (Math.abs(x) + exX > hw + 1e-9 || Math.abs(z) + exZ > hd + 1e-9) { + out.push(`${label} at (${x}, ${z}) is outside the ${w}×${d} m yard`); + } + }; + for (const p of site.posts ?? []) check(`post ${p.id}`, p.x, p.z); + for (const t of site.trees ?? []) check(`tree ${t.id}`, t.x, t.z); + for (const s of site.structures ?? []) check(`structure ${s.id}`, s.x, s.z); + for (const k of ['house', 'shed', 'shedTable', 'gnome', 'bike']) { + const e = site[k]; + if (e) check(k, e.x, e.z); + } + const g = site.gardenBed; + if (g) check('the garden bed', g.x, g.z, (g.w ?? 0) / 2, (g.d ?? 0) / 2); + return out; +} + /** Coordinates are rounded where they're WRITTEN, so export stays pure. */ const round3 = (n) => Math.round(n * 1000) / 1000; @@ -442,7 +487,7 @@ export async function createEditor(opts) { let site = emptyTemplate(); let world = null; let selection = null; - let validationCache = { ok: true, errors: [], warnings: [] }; + let validationCache = { ok: true, errors: [], warnings: [], bounds: [] }; let rebuilding = false; let rebuildMs = 0; let lastStatus = 'template'; @@ -548,7 +593,15 @@ export async function createEditor(opts) { errors.push(...lines.slice(1)); if (!errors.length) errors.push(String(err.message)); } - validationCache = { ok: errors.length === 0, errors, warnings: adoptionWarnings() }; + validationCache = { + ok: errors.length === 0, + errors, + warnings: adoptionWarnings(), + // bounds ride in the cache so the header, the panel and the export all + // read ONE measurement — three surfaces disagreeing about where the + // fence is would be the two-harness disease in miniature. + bounds: boundsFaults(site), + }; return validationCache; } @@ -606,6 +659,9 @@ export async function createEditor(opts) { const RING_GEO = new THREE.RingGeometry(0.72, 0.92, 28).rotateX(-Math.PI / 2); const DOT_GEO = new THREE.RingGeometry(0.34, 0.44, 20).rotateX(-Math.PI / 2); const DOT_MAT = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.28 }); + // The yard rectangle (gate 3.2). Amber, not red: out-of-bounds is a warning + // state and the line itself is neutral information. + const BOUNDS_MAT = new THREE.LineBasicMaterial({ color: 0xd9a94a, transparent: true, opacity: 0.55 }); /** * The editor picks its OWN invisible handles rather than the dressed meshes. @@ -619,6 +675,24 @@ export async function createEditor(opts) { */ function rebuildHandles() { handles.clear(); + // The yard rectangle, drawn as a line at ankle height — the fence only + // draws on sides the site enables, so "where do the bounds end" was + // invisible on open sides and objects placed past them silently (D's + // gate-3.2). Rebuilt with the handles because yard w/d are editable. + { + const hw = (site.yard?.width ?? 0) / 2, hd = (site.yard?.depth ?? 0) / 2; + if (hw > 0 && hd > 0) { + const pts = [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map( + ([x, z]) => new THREE.Vector3(x, heightAt(x, z) + 0.06, z), + ); + const loop = new THREE.LineLoop( + new THREE.BufferGeometry().setFromPoints(pts), + BOUNDS_MAT, + ); + loop.renderOrder = 1; + handles.add(loop); + } + } for (const ref of allRefs()) { const e = entryFor(ref); if (!e) continue; @@ -911,6 +985,10 @@ export async function createEditor(opts) { remove(selection); } if (ev.key === 'Escape') { armed = null; setTool(null); } + // Keyboard zoom (D's gate-3 friction: wheel-only). Same curve as the + // wheel handler — one Exp step ≈ three notches — so the two agree. + if (ev.key === '+' || ev.key === '=') { cam.dist *= Math.exp(-0.12); applyCamera(); } + if (ev.key === '-' || ev.key === '_') { cam.dist *= Math.exp(0.12); applyCamera(); } }); // ======================================================================= @@ -937,16 +1015,34 @@ export async function createEditor(opts) { function numInput(get, set, step = 0.1) { const i = el('input', 'ed-num'); i.type = 'number'; i.step = String(step); i.value = String(get() ?? 0); - i.addEventListener('change', () => { + // Commit on Enter AND change (D's gate-3 friction: "Enter does nothing, + // and a house sat on the garden bed while the panel read the new value"). + // Idempotent on purpose — Enter then blur fires both paths, and a no-op + // commit must not pay a rebuild. + const commit = () => { const v = Number(i.value); - if (Number.isFinite(v)) { set(round3(v)); rebuild(); } + if (!Number.isFinite(v) || round3(v) === get()) return; + set(round3(v)); + rebuild(); + }; + i.addEventListener('change', commit); + i.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); return i; } function textInput(get, set) { const i = el('input', 'ed-text'); i.type = 'text'; i.value = get() ?? ''; - i.addEventListener('change', () => { set(i.value); markDirty(); }); + const commit = () => { + if (i.value === (get() ?? '')) return; // same idempotence as numInput + set(i.value); + markDirty(); + }; + i.addEventListener('change', commit); + i.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); commit(); } + }); return i; } @@ -1010,6 +1106,15 @@ export async function createEditor(opts) { } b.append(sides); + // Gate 3.4's ruling, said where an author would look for the field: there + // is deliberately NO separation input. A `separation` block is a MEASURED + // promise (gardenfly's held-vs-bare, through the real chain), and a + // hand-typed one would be a wish wearing a pin's clothes. SCORE IT + // measures it; the pin lands at integration (MANUAL, site cookbook §5). + b.append(el('p', 'ed-note', + 'No separation field, on purpose: separation is a measured pin, not an ' + + 'authored wish. Run SCORE IT and pin the measured number at integration.')); + b.append(el('p', 'ed-label', '_design'), notesInput('_design')); b.append(el('p', 'ed-note', 'Why this yard exists and what you measured. Ships in the JSON.')); b.append(el('p', 'ed-label', '_why'), notesInput('_why')); @@ -1173,7 +1278,14 @@ export async function createEditor(opts) { for (const w of v.warnings) list.append(el('li', 'ed-warn', w)); b.append(list); } - if (v.ok && !v.warnings.length) { + if (v.bounds?.length) { + b.append(el('p', 'ed-warn', + `${v.bounds.length} outside the yard — the game boots this, but is it what you meant?`)); + const list = el('ul', 'ed-list'); + for (const w of v.bounds) list.append(el('li', 'ed-warn', w)); + b.append(list); + } + if (v.ok && !v.warnings.length && !v.bounds?.length) { b.append(el('p', 'ed-note', 'Validation is structural only: it says the game can BOOT this yard, not that ' + 'the yard is a game. That question is SCORE IT.')); @@ -1314,7 +1426,9 @@ export async function createEditor(opts) { if (world && !rebuilding) world.update(1 / 60, t); readout.textContent = `${site.id} ${site.yard.width}×${site.yard.depth} m\n` - + `anchors ${world?.anchors.length ?? 0} ${validationCache.ok ? 'valid' : 'INVALID'}\n` + + `anchors ${world?.anchors.length ?? 0} ${validationCache.ok + ? (validationCache.bounds?.length ? `valid · ${validationCache.bounds.length} OUTSIDE BOUNDS` : 'valid') + : 'INVALID'}\n` + `rebuild ${rebuildMs.toFixed(0)} ms ${lastStatus}`; renderer.render(scene, camera); }); diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 6d569ca..6b21392 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -22,7 +22,7 @@ import { SailRig, orderRing } from '../sail.js'; import { loadStorm, createWind } from '../weather.js'; import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js'; import { createHud } from '../hud.js'; -import { PALETTE, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js'; +import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js'; import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js'; /** @param {import('../testkit.js').Suite} t */ @@ -1361,6 +1361,32 @@ export default async function run(t) { assertEq(again, exported, 'export → parse → export changed the bytes'); }); + t.test('editor: out of bounds is SEEN — faults, and a loud export key (S15 gate 3.2)', () => { + const site = emptyTemplate(); // 24×16 → half-extents 12×8 + assertEq(boundsFaults(site).join('; '), '', 'the template itself must be in bounds'); + placeEntry(site, postItem, 14, 0); // 2 m past the east bound + const faults = boundsFaults(site); + assertEq(faults.length, 1, 'one post 2 m outside must be exactly one fault'); + assert(faults[0].includes('post') && faults[0].includes('outside'), + `fault text should name the thing and the problem: ${faults[0]}`); + // The garden bed counts its EDGES — centre in bounds, half its width out. + const bedSite = emptyTemplate(); + bedSite.gardenBed = { x: 11, z: 0, w: 5, d: 3.5 }; // 11 + 2.5 > 12 + assertEq(boundsFaults(bedSite).length, 1, + 'a bed hanging over the fence must fault even with its centre inside'); + // Exactly ON the line is legal — snapping to the fence is not a mistake. + const edgeSite = emptyTemplate(); + edgeSite.posts[0].x = 12; edgeSite.posts[0].z = 8; + assertEq(boundsFaults(edgeSite).join('; '), '', 'on-the-line must not fault'); + // The export wears it, FIRST (a human reads the top of a diff), and only + // when it is true — a clean yard must not carry the key. + const out = JSON.parse(exportSiteJSON(site, { ok: true, errors: [], bounds: faults })); + assert(Array.isArray(out._OUT_OF_BOUNDS), 'export must carry _OUT_OF_BOUNDS when a fault exists'); + assertEq(Object.keys(out)[0], '_OUT_OF_BOUNDS', 'the bounds key must be the first key in the file'); + const clean = JSON.parse(exportSiteJSON(emptyTemplate(), { ok: true, errors: [], bounds: [] })); + assert(!('_OUT_OF_BOUNDS' in clean), 'a clean yard must not carry the key'); + }); + t.test('editor export ignores key INSERTION order', () => { // The determinism that actually matters. Two objects that ARE the same // yard but were assembled in a different order must serialise identically,