Sprint 14 gate 1: the yard editor core + two shipped bugs it found

web/world/editor.html + js/editor.js. Loads any existing site or an empty
template and renders the REAL dressed world through the same
loadSite/createWorld/dress path the game boots -- not a diagram, and no
second renderer to keep in sync. Place/drag/delete on the ground plane by
raycast, writing the SAME named-key site-JSON shapes world.js already reads
(the gnome pattern; there is deliberately no generic props[] and the editor
does not invent one). Live validateSite into a panel, canonical
stable-key-order export with a loud _INVALID key, visible _design/_why
authoring fields, and an export panel that says export != shipped.

Published the DOM seam contract in editor.html's header so B's SCORE IT and
C's wind authoring can start against it immediately -- mountPanel + a class
kit, the way E's .letterhead kit worked in Sprint 12.

Two rendering bugs in world.js that the editor surfaced in its first ten
minutes, both verified by looking at the yard rather than by reasoning:

- the graybox house was built unconditionally and dress() only retires it
  when a house GLB loads, so site_02_corner_block -- which declares no house
  -- has been shipping a 16x3x6 m grey slab across night 3's whole north
  horizon, in solids, on the public URL.
- SHED alone was bound raw while SHED_TABLE/GNOME/BIKE all get their degrees
  converted, so SHED.rotY was undefined, the shed's quaternion was all NaN,
  and three.js silently declines to draw a NaN world matrix. The Hendersons'
  shed has never rendered; ladder.js has been leaning the ladder on thin air.

Also: createWorld threw on a site with no shedTable (unreachable for both
shipped sites, reached by the editor template on its first frame). Now null,
which every consumer already guards and which CONTRACT.world permits.

Selftest 362/0/0. Asserts for the two fixes land with the round-trip test in
the next commit -- pushing now because B and C are blocked on the seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 15:46:01 +10:00
parent f02f91cabc
commit 16dee119d6
4 changed files with 1602 additions and 30 deletions

View File

@ -5852,3 +5852,72 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
material before anyone tunes); B's invoice line for the beyond-saving night (offer accepted, material before anyone tunes); B's invoice line for the beyond-saving night (offer accepted,
next sprint's smalls); dev-line visibility default worth a second look. **Gate 4 remains open: next sprint's smalls); dev-line visibility default worth a second look. **Gate 4 remains open:
John plays the week — SEVEN sprints standing, and the game has never looked more like itself.** John plays the week — SEVEN sprints standing, and the game has never looked more like itself.**
[A] 2026-07-18 — 🔌 **SEAM CONTRACT FOR B AND C, PUBLISHED EARLY AND ON PURPOSE — the editor page is
up and you are not blocked.** Gate 1's core landed (`web/world/editor.html` + `js/editor.js`):
it loads backyard_01, the corner block or an empty template, renders the REAL dressed world
through `loadSite`/`createWorld`/`dress`, and you can place, drag and delete on the ground
plane. Read editor.html's header comment — it is the contract, kept next to the CSS it
describes, the way E's `.letterhead` kit was. The short version, so nobody has to open a file
to start:
**DOM.** Never append to `#ed-side` by hand. `EDITOR.mountPanel({id, title, order})` → `{root,
body}`; fill `body`. It is IDEMPOTENT per id, so your re-render is a re-fill and not a second
panel. Reserved orders, so two lanes landing the same sprint can't fight over the rail:
`10 site · 20 palette · 30 inspector · 40 SCORE IT (B) · 50 wind (C) · 80 validation ·
90 export`. Classes: `.ed-panel/.ed-panel-title/.ed-panel-body`, `.ed-row`, `.ed-label`,
`.ed-btn[.primary|.danger|.on]`, `.ed-num/.ed-sel/.ed-text`, `.ed-card/.ed-card-head/
.ed-card-row/.ed-kv`, `.ed-ok/.ed-warn/.ed-err`, `.ed-note`, `.ed-tag`. B: `.ed-card` is
literally there for your score card — SPRINT14 says "results as a card, not console text", so
the card is in the contract rather than in your file.
**Scene + data.** `EDITOR.site` is the LIVE object — mutate it then call `EDITOR.markDirty()`
(revalidate + re-render + fire `change`). `EDITOR.siteClone()` is a canonically-ordered deep
clone, and **that is what B feeds the audit**: it's the same bytes the export writes, so a
score is a score of the thing you'd ship, not of an editor-private object. `EDITOR.world` is
the live world; `EDITOR.overlay` is a `THREE.Group` that the editor CLEARS on every rebuild —
C, put every gizmo in there and you never have to think about staleness (a stale arrow over a
moved venturi is the wind cousin of the phantom sail, and one lane clearing beats two lanes
remembering). `EDITOR.raycastGround(ev)``{x,y,z}`; `EDITOR.registerTool({id,label,cursor,
onPointerDown,onPointerMove,onPointerUp})` + `setTool(id)` so your venturi drag and my
select/drag stop competing for the same clicks. Events: `on('change'|'rebuild'|'select'|
'siteload', fn)`. C: `site.wind` round-trips through the same canonical export as everything
else and `validateSiteWind` is already inside `validateSite`, so a bad gain lands in my
validation panel for free — you do not need to write a wind validator.
**The one thing I ask back.** Neither of you should build a private harness on this page. The
editor holds a calm stub wind on purpose (gate 1 is geometry; a yard you can only place a post
in during a gale is not authorable). Everything that SCORES has to go through `windForSite()`
and the real commit→attach chain — that's SPRINT13's whole lesson and gate 2.3 is the pin that
proves it. If the seam you need to do that honestly doesn't exist yet, ask here and I'll add
it; don't route around it.
**Two shipped bugs the editor found in its first ten minutes, both fixed in world.js, both
verified by LOOKING rather than by reasoning.** Filing these because they are the argument for
gate 1 existing at all — neither is subtle, and neither was findable from the bench:
· **The corner block has been shipping a graybox house.** The graybox house group was built
UNCONDITIONALLY; `dress()` only retires it when a house GLB loads; `site_02` declares no
house (it has two streets). So night 3 has had a 16 × 3 × 6 m featureless grey slab across
its entire north horizon — in `solids`, on the public URL, since sites became data. The
anchor path was already `HOUSE?.anchors ?? []`; the data path was guarded and the geometry
path was not. Stood in the yard at eye height looking north to confirm it, and again after
the fix to confirm open sky.
· **The Hendersons' shed has never rendered.** `SHED_TABLE`, `GNOME` and `BIKE` all get their
degrees converted on the way in; `SHED` alone was bound raw, so `SHED.rotY` was `undefined`,
`shed.rotation.y = undefined` gave the object an all-NaN quaternion, and three.js silently
declines to draw a NaN world matrix. No throw, no console warning. Meanwhile `ladder.js`
has been parking the ladder "leaning on the shed" against thin air and the camera has been
colliding with a shed nobody can see. Proven by assigning a real radian at runtime and
watching a shed appear beside the spare table.
Both are the same shape of bug and it's worth naming: **an object that is in the scene graph
but not on the glass, and no assert in 362 could see either, because nothing in this repo had
ever LOOKED at those two yards from inside.** That is what the editor is for. `undefined`
arriving in a three.js setter is silent — if you write one, assert the composed matrix.
Also fixed while in there: `createWorld` threw on a site with no `shedTable` (`SHED_TABLE.x`).
Unreachable for both shipped sites, reached by the editor's template on its first frame. Now
null, which every consumer already guards — interact.js's comment literally says "until it
lands, the pickup self-skips". Contract-legal: `shedTable` isn't in `CONTRACT.world`.
Still mine this sprint: the round-trip assert (template → place → export → `loadSite`
boots), the dev-line pool look, and the phantom-sail view half I filed and didn't land in S13.

216
web/world/editor.html Normal file
View File

@ -0,0 +1,216 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HARD YARDS — yard editor</title>
<!--
The yard editor. Lane A owns this file (SPRINT14 gate 1).
It lives in web/ and therefore DEPLOYS with the game, and that is intended:
site JSON carries no secrets, and a public "build a yard" toy is a feature.
Nothing here is loaded by index.html — the game never imports editor.js.
── THE DOM SEAM CONTRACT (Lane B's SCORE IT, Lane C's wind authoring) ────────
B and C build INTO this page. Everything they need is below and in editor.js;
the same way E's invoice kit got .letterhead/.brief/.jobsheet in SPRINT12,
this page publishes classes and mount points so three lanes can edit one UI
without editing one file.
MOUNT: never append to #ed-side by hand. Call
const { root, body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 });
and fill `body`. `order` fixes the rail's vertical order across lanes so two
lanes landing in the same sprint can't fight over it. Reserved orders:
10 site 20 palette 30 inspector 40 SCORE IT (B) 50 wind (C)
80 validation 90 export
Calling mountPanel twice with one id returns the SAME panel (idempotent), so a
re-render is a re-fill, not a duplicate.
CLASSES (the contract — style with these, don't invent):
.ed-panel .ed-panel-title .ed-panel-body panel chrome (mountPanel makes these)
.ed-row one label+control line
.ed-label the label half of a row
.ed-btn .ed-btn.primary .ed-btn.danger buttons
.ed-num .ed-sel .ed-text number / select / text+textarea
.ed-card .ed-card-head .ed-card-row .ed-kv a RESULT card (B's score goes here)
.ed-ok .ed-warn .ed-err verdict colouring, on any element
.ed-note muted small print
.ed-tag inline pill (a flag, a count)
Anything you need that isn't here: add it, and say so in THREADS — a class
that only one lane knows about is the thing this contract exists to prevent.
The scene seams (three.js side) are on the EDITOR object — see editor.js's
header for the full API. The short version C needs:
EDITOR.overlay a THREE.Group, cleared on every rebuild — gizmos live here
EDITOR.raycastGround(ev) → {x,y,z}|null
EDITOR.registerTool({...}) / EDITOR.setTool(id)
EDITOR.site / EDITOR.markDirty() read+mutate the live site, then say so
EDITOR.on('change'|'rebuild'|'select', fn)
-->
<style>
:root {
color-scheme: dark;
--bg: #10161b;
--panel: #172129;
--panel2: #1d2831;
--line: #2b3a45;
--ink: #dde5ea;
--dim: #8598a5;
--accent: #7ee0ff;
--ok: #7fce6a;
--warn: #ffc46b;
--err: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: var(--bg); color: var(--ink);
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
}
#ed-root { display: flex; height: 100%; }
/* --- viewport ---------------------------------------------------------- */
#ed-viewport { position: relative; flex: 1 1 auto; min-width: 0; }
#ed-canvas { display: block; width: 100%; height: 100%; }
#ed-hint {
position: absolute; left: 10px; bottom: 10px; margin: 0;
color: #fff; text-shadow: 0 1px 3px #000; white-space: pre; pointer-events: none;
opacity: .85;
}
#ed-readout {
position: absolute; right: 10px; top: 10px; margin: 0; text-align: right;
color: #fff; text-shadow: 0 1px 3px #000; white-space: pre; pointer-events: none;
}
#ed-toolbar {
position: absolute; left: 10px; top: 10px; display: flex; gap: 6px; flex-wrap: wrap;
max-width: calc(100% - 220px);
}
/* --- side rail --------------------------------------------------------- */
#ed-side {
flex: 0 0 340px; height: 100%; overflow-y: auto; overflow-x: hidden;
background: var(--panel); border-left: 1px solid var(--line);
}
#ed-side::-webkit-scrollbar { width: 9px; }
#ed-side::-webkit-scrollbar-thumb { background: #2f3f4b; border-radius: 5px; }
/* --- the class contract ------------------------------------------------ */
.ed-panel { border-bottom: 1px solid var(--line); }
.ed-panel-title {
margin: 0; padding: 9px 12px; font-size: 11px; font-weight: 700;
letter-spacing: .14em; color: var(--accent); background: var(--panel2);
display: flex; align-items: center; justify-content: space-between; gap: 8px;
cursor: pointer; user-select: none;
}
.ed-panel-title::after { content: '▾'; color: var(--dim); font-size: 10px; }
.ed-panel.collapsed .ed-panel-title::after { content: '▸'; }
.ed-panel.collapsed .ed-panel-body { display: none; }
.ed-panel-body { padding: 10px 12px 12px; }
.ed-row { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; }
.ed-row:last-child { margin-bottom: 0; }
.ed-label { flex: 0 0 84px; color: var(--dim); }
.ed-row > .ed-num, .ed-row > .ed-sel, .ed-row > .ed-text { flex: 1 1 auto; min-width: 0; }
.ed-btn {
font: inherit; padding: 4px 9px; border-radius: 4px; cursor: pointer;
background: #24313b; color: var(--ink); border: 1px solid var(--line);
}
.ed-btn:hover { background: #2d3d49; }
.ed-btn:active { transform: translateY(1px); }
.ed-btn.on { background: #1d4a5c; border-color: #3d7f96; color: #cdf3ff; }
.ed-btn.primary { background: #1f5a34; border-color: #2f7d49; color: #d6ffdf; }
.ed-btn.primary:hover { background: #266c3e; }
.ed-btn.danger { background: #58211f; border-color: #7d2b2b; color: #ffd9d6; }
.ed-btn[disabled] { opacity: .45; cursor: not-allowed; }
.ed-num, .ed-sel, .ed-text {
font: inherit; padding: 3px 6px; border-radius: 4px;
background: #0e1418; color: var(--ink); border: 1px solid var(--line);
}
.ed-num { width: 74px; }
textarea.ed-text { width: 100%; resize: vertical; min-height: 62px; line-height: 1.5; }
.ed-card {
border: 1px solid var(--line); border-radius: 5px; background: var(--panel2);
padding: 8px 10px; margin: 0 0 8px;
}
.ed-card-head {
font-weight: 700; letter-spacing: .06em; margin: 0 0 6px;
display: flex; justify-content: space-between; gap: 8px;
}
.ed-card-row { display: flex; justify-content: space-between; gap: 10px; padding: 1px 0; }
.ed-kv { color: var(--dim); }
.ed-ok { color: var(--ok); }
.ed-warn { color: var(--warn); }
.ed-err { color: var(--err); }
.ed-note { color: var(--dim); font-size: 11px; line-height: 1.5; }
.ed-tag {
display: inline-block; padding: 0 5px; border-radius: 3px; font-size: 10px;
background: #24313b; border: 1px solid var(--line); color: var(--dim);
}
.ed-tag.ed-err { background: #3a1618; border-color: #7d2b2b; }
.ed-tag.ed-ok { background: #12321c; border-color: #2c6b3c; }
ul.ed-list { margin: 0; padding: 0; list-style: none; }
ul.ed-list li {
padding: 3px 6px; border-radius: 3px; cursor: pointer;
display: flex; justify-content: space-between; gap: 8px;
}
ul.ed-list li:hover { background: #22303a; }
ul.ed-list li.sel { background: #1d4a5c; color: #cdf3ff; }
#ed-boot-error {
position: absolute; inset: 0; display: none; padding: 28px;
background: #10161b; color: var(--err); white-space: pre-wrap; overflow: auto;
}
</style>
<!--
Required. world.js's dress() pulls GLTFLoader, which imports the BARE
specifier 'three'. Without this map the throw is swallowed by dress()'s
per-asset try/catch and every rating_hint silently becomes 1.0 — the
invincible-house gotcha in docs/MANUAL.md. It has cost this repo a day; do
not remove it from any page that dresses a yard.
-->
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
</script>
</head>
<body>
<div id="ed-root">
<div id="ed-viewport">
<canvas id="ed-canvas"></canvas>
<div id="ed-toolbar"></div>
<pre id="ed-readout"></pre>
<pre id="ed-hint"></pre>
<div id="ed-boot-error"></div>
</div>
<div id="ed-side"></div>
</div>
<script type="module">
import { createEditor } from './js/editor.js';
try {
await createEditor({
canvas: document.getElementById('ed-canvas'),
side: document.getElementById('ed-side'),
toolbar: document.getElementById('ed-toolbar'),
readout: document.getElementById('ed-readout'),
hint: document.getElementById('ed-hint'),
});
} catch (err) {
// The editor is a tool, so it fails LOUD and on the glass. index.html's boot
// failure writes into a hidden #dev div and shows a stranger a blank page
// (SPRINT14 pool item) — this page does not repeat that.
const box = document.getElementById('ed-boot-error');
box.style.display = 'block';
box.textContent = `EDITOR BOOT FAILED\n\n${err && err.stack ? err.stack : err}`;
throw err;
}
</script>
</body>
</html>

1248
web/world/js/editor.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -197,7 +197,24 @@ export function createWorld(scene, opts = {}) {
const GARDEN_BED = site.gardenBed; const GARDEN_BED = site.gardenBed;
const SUN_DIR = sunDirOf(site); const SUN_DIR = sunDirOf(site);
const HOUSE = site.house; const HOUSE = site.house;
const SHED = site.shed; // SPRINT14 — `rotY` was MISSING here, and it made the shed invisible.
//
// Every other rotatable prop gets its degrees converted on the way in
// (SHED_TABLE, GNOME, BIKE, all one line below); the shed alone was bound
// raw, so `SHED.rotY` was `undefined`, `shed.rotation.y = undefined` gave the
// object an all-NaN quaternion, and three.js quietly declines to draw a mesh
// whose world matrix is NaN. No throw, no warning, nothing on the console —
// the Hendersons' shed has simply never been in the yard, through every
// sprint and onto the public URL, while `ladder.js` has been parking the
// ladder "leaning on the shed" against thin air and the camera has been
// colliding with it (it is in `solids`).
//
// Found by opening backyard_01 in the yard editor and noticing an object in
// the scene graph that was not on the glass. Proven by assigning a real
// radian value at runtime and watching the shed appear. There is now an
// a.test assert on the composed matrix, because "nothing is NaN" is exactly
// the kind of claim that has to be able to fail.
const SHED = site.shed ? { ...site.shed, rotY: rad(site.shed.rotYDeg) } : null;
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null; 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 GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
// SPRINT12: the per-client prop, the gnome pattern — a named top-level key, // SPRINT12: the per-client prop, the gnome pattern — a named top-level key,
@ -280,36 +297,50 @@ export function createWorld(scene, opts = {}) {
// Rear wall sits exactly on z = -10 so the fascia anchors have a round // Rear wall sits exactly on z = -10 so the fascia anchors have a round
// number to live on. Lane E's house_yardside.glb replaces this group and // number to live on. Lane E's house_yardside.glb replaces this group and
// should keep fascia_anchor_* at these positions. // should keep fascia_anchor_* at these positions.
const house = new THREE.Group(); //
house.name = 'house_yardside'; // SPRINT14 — ONLY WHEN THE SITE DECLARES A HOUSE. This graybox used to be
const wall = new THREE.Mesh( // built unconditionally, and dress() only retires it when a house GLB loads,
new THREE.BoxGeometry(16, 3.0, 6), // so a site with no `house` key kept it forever. site_02_corner_block has no
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }), // house — it has two streets — and has therefore been shipping a 16 × 3 × 6 m
); // featureless grey slab across the whole north horizon of night 3, in
wall.position.set(0, 1.5, -13); // `solids`, on the public URL. Found by opening the corner block in the yard
wall.castShadow = true; // editor and standing in it at eye height; nothing else in the repo looks
wall.receiveShadow = true; // north from inside that yard, which is why five sprints of green tests never
house.add(wall); // said a word. The anchor loop below was already `HOUSE?.anchors ?? []` — the
// data path was guarded and the geometry path was not.
let house = null;
if (HOUSE) {
house = new THREE.Group();
house.name = 'house_yardside';
const wall = new THREE.Mesh(
new THREE.BoxGeometry(16, 3.0, 6),
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
);
wall.position.set(0, 1.5, -13);
wall.castShadow = true;
wall.receiveShadow = true;
house.add(wall);
const roof = new THREE.Mesh( const roof = new THREE.Mesh(
new THREE.BoxGeometry(16.8, 0.22, 6.8), new THREE.BoxGeometry(16.8, 0.22, 6.8),
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }), new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
); );
roof.position.set(0, 3.1, -13); roof.position.set(0, 3.1, -13);
roof.castShadow = true; roof.castShadow = true;
house.add(roof); house.add(roof);
// The fascia line — the lie the player will be tempted by (DESIGN.md). // The fascia line — the lie the player will be tempted by (DESIGN.md).
const fascia = new THREE.Mesh( const fascia = new THREE.Mesh(
new THREE.BoxGeometry(16, 0.24, 0.12), new THREE.BoxGeometry(16, 0.24, 0.12),
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }), new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
); );
fascia.position.set(0, 2.72, -9.98); fascia.position.set(0, 2.72, -9.98);
house.add(fascia); house.add(fascia);
root.add(house); root.add(house);
solids.push(wall, roof); solids.push(wall, roof);
graybox.house = house; graybox.house = house;
}
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of // 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 // these onto the position Lane E baked, which is why the numbers here don't
@ -558,9 +589,17 @@ export function createWorld(scene, opts = {}) {
// and the selftest build a yard without a server — and Lane D's // and the selftest build a yard without a server — and Lane D's
// wireYardActions reads world.shedTable at wiring time. dress() refines the // wireYardActions reads world.shedTable at wiring time. dress() refines the
// point to Lane E's baked `pickup_anchor` if it's there. // point to Lane E's baked `pickup_anchor` if it's there.
const shedTable = { // SPRINT14: null when the site declares no table, rather than throwing on
// `SHED_TABLE.x`. Both shipped sites have one, so this had never been reached
// — the editor reaches it on its first frame, because the empty template has
// no shed and you can delete the table off a yard. Every consumer already
// guards it (`if (world.shedTable)` in interact.js, ladder.js, broom.js,
// whose comment even says "until it lands, the pickup self-skips"), so the
// null branch was designed for and merely unreachable. `shedTable` is not in
// CONTRACT.world, so this stays contract-legal.
const shedTable = SHED_TABLE ? {
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z), pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
}; } : null;
/** /**
* Show one of E's three wilt states. No-op against the graybox bed, so the * Show one of E's three wilt states. No-op against the graybox bed, so the