editor: the honest edges — Enter commits, bounds are seen, keyboard zoom, separation ruled (S15 gate 3 + gate 1.1 ruling)
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 <noreply@anthropic.com>
This commit is contained in:
parent
3dee2a7c83
commit
c182fc14cb
54
THREADS.md
54
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).
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user