HardYards/web/world/js/world.js
type-two 16dee119d6 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>
2026-07-18 15:46:01 +10:00

1018 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SHADES — the yard. Lane A owns this file.
*
* M0 is deliberately graybox: every mesh here is a stand-in with the right
* dimensions and the right NAME, so Lane E's real GLBs drop into the same slots
* without anyone re-deriving positions. What is NOT placeholder, and what other
* lanes should build against, is the layout: anchor positions, ground heights,
* the garden rect and the sun direction.
*
* Geometry conventions: meters, +Y up, origin at yard centre on the ground.
* North (-Z) is the house edge. The yard is 30 (x) by 20 (z).
*/
import * as THREE from '../vendor/three.module.js';
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
import { validateSiteWind } from './weather.core.js';
/**
* Full strength: this anchor takes whatever its hardware is rated for.
*
* SPRINT11 — Lane D's landmine, defused before anyone can step on it. Every
* anchor now carries a NUMBER here, always. It used to be set only by
* `adoptAnchor`, off Lane E's baked `rating_hint` — so GLB-backed anchors had it
* and the ones built straight from site JSON (site_02's honest posts q1..q4) did
* not. Their `ratingHint` was `undefined`.
*
* That matters the moment anyone wires the ratings into the failure threshold,
* which is the open question D put to me. The naive wire is
* `load > hw.rating * anchor.ratingHint`, and with `undefined` that is
* `load > NaN` — **always false**. The honest posts wouldn't read weak, they'd
* be UNBREAKABLE, and the carport would become the only anchor in the yard that
* can fail. The trap would appear to work, for entirely the wrong reason, and it
* would have looked like a successful playtest.
*
* "Default before you multiply" — D's words. This is the default, at the source,
* so no consumer has to remember. 1 = honest steel; E's traps ship lower.
*/
const DEFAULT_RATING_HINT = 1;
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
/**
* Ground height in meters at a world XZ. Pure and cheap — this is called by the
* player every frame and by every piece of debris, so it stays closed-form
* rather than sampling a texture. Gentle by design (about ±0.3 m): enough that
* water has somewhere to go and the yard doesn't read as a table, not so much
* that it fights the rigging puzzle.
*
* @param {number} x
* @param {number} z
* @returns {number}
*/
export function heightAt(x, z) {
return (
0.18 * Math.sin(x * 0.21 + 1.3) * Math.cos(z * 0.27 - 0.4) +
0.09 * Math.sin(x * 0.53 - 2.1) * Math.sin(z * 0.41 + 0.8) -
0.06 * Math.cos(x * 0.11)
);
}
const SITE_DIR = new URL('../data/sites', import.meta.url).href;
/**
* Load a site. SPRINT10 gate 1 — the yard stopped being code today.
*
* Everything that used to be a constant in this file (bed rect, house line,
* tree and post placements, shed, gnome, sun angle) is now `data/sites/*.json`,
* and world.js is a builder. The proof of a faithful extraction is that nothing
* moved: Lane E's anchor tripwires pin five branch positions to 6 dp and
* a.test's quad-band asserts pin the whole rigging geometry, so a byte-identical
* backyard_01 is checkable rather than claimable.
*
* Async and separate from createWorld() for the same reason dress() is: the
* selftest builds yards with no server, and a fetch in the constructor is either
* a break or a flake. Callers load the site, then build.
*
* @param {string} name e.g. 'backyard_01'
*/
export async function loadSite(name, dir = SITE_DIR) {
const url = `${dir}/${name}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`world: cannot load site ${url} (${res.status})`);
return validateSite(await res.json(), name);
}
/**
* Sites are content, so they fail loud rather than half-build. Every field this
* checks is one that would otherwise produce a yard that looks fine and is
* subtly wrong — an absent bed silently becomes NaN coverage, a missing anchor
* list silently becomes an unriggable site.
*/
export function validateSite(site, name = site?.id ?? '?') {
const bad = [];
if (!site || typeof site !== 'object') bad.push('not an object');
else {
if (!site.yard?.width || !site.yard?.depth) bad.push('yard.width/depth');
if (!site.gardenBed) bad.push('gardenBed');
if (!Number.isFinite(site.sun?.elevationDeg)) bad.push('sun.elevationDeg');
if (!Number.isFinite(site.sun?.azimuthDeg)) bad.push('sun.azimuthDeg');
const ids = [
...(site.house?.anchors ?? []).map((a) => a.id),
...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => a.id)),
...(site.posts ?? []).map((p) => p.id),
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => a.id)),
];
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
// tensioned from a cleat at its base and a tree strop is thrown, but a
// bracket bolted up a bare wall is the one you cannot fake — so that, and
// only that, wants the ladder. Keyed on mechanism because `type` is a closed
// enum ('house'|'tree'|'post') that site_02's carport doesn't fit, and
// needsLadder keyed on type FAILS OPEN there: the ladder mechanic silently
// vanishes and you re-rig a 2.6 m bracket standing on the grass (D's audit).
const works = [
...(site.house?.anchors ?? []),
...(site.trees ?? []).flatMap((t) => t.anchors ?? []),
...(site.posts ?? []),
...(site.structures ?? []).flatMap((s) => s.anchors ?? []),
];
for (const a of works) {
if (a.work !== 'cloth' && a.work !== 'bracket') {
bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
}
// SPRINT11 — the enum is CHECKED now, not just documented. It was a JSDoc
// comment before, which is why site_02 could type a carport 'post' and
// nothing said a word (D's flag). An unenforced enum is decoration; this
// one fails loud, so the next site that doesn't fit argues with the list
// in contracts.js instead of quietly joining the wrong family.
if (!ANCHOR_TYPE.includes(a.type)) {
bad.push(`anchor ${a.id}: type ${JSON.stringify(a.type)} is not one of ${ANCHOR_TYPE.join('|')}`);
}
}
// C's validator, wired at site load as they asked in SPRINT10: a site's wind
// block is hand-authored data, so it fails loud like a storm's rather than
// being silently clamped. A site with no wind block passes (backyard_01 has
// no funnel) — that's C's contract, not an accident.
bad.push(...validateSiteWind(site.wind, name).errors);
}
if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`);
return site;
}
/** Direction from the GROUND toward the SUN (see contracts.js), off site data. */
function sunDirOf(site) {
const el = (site.sun.elevationDeg * Math.PI) / 180;
const az = (site.sun.azimuthDeg * Math.PI) / 180;
return new THREE.Vector3(
Math.cos(el) * Math.sin(az),
Math.sin(el),
Math.cos(el) * Math.cos(az),
).normalize();
}
const COLORS = {
sky: 0x9fc4dd,
grass: 0x4a7c3f,
soil: 0x6b4a2f,
plant: 0x7fce6a,
house: 0x8a8f96,
trim: 0x6e737a,
bark: 0x5a3d24,
leaf: 0x2f6b28,
steel: 0x9aa4ad,
timber: 0x7a6a4f,
};
// (kept explicit rather than clever — Lane E will replace these with materials
// baked into the GLBs, at which point this table shrinks to nothing.)
/**
* Build the yard.
*
* @param {THREE.Scene} scene
* @param {object} [opts]
* @param {import('./contracts.js').Wind} [opts.wind]
* Wind is injected because tree anchors sway with it, and sway is dynamic
* load — the whole reason a tree anchor is scarier than a post. Defaults to
* the calm stub so world.js is usable headless before Lane C lands.
* @returns {import('./contracts.js').World}
*/
export function createWorld(scene, opts = {}) {
const wind = opts.wind ?? createStubWind({ calm: true });
// SPRINT10 gate 1. Every one of these was a module constant in this file an
// hour ago; they're site data now. They keep their old local names on purpose
// — the builder below is ~500 lines of geometry that was already correct, and
// rebinding the names is a change of SOURCE, not of behaviour. That's what
// makes "backyard_01 is byte-identical" a structural claim rather than a hope.
const site = validateSite(
opts.site ?? (() => { throw new Error(
'world: createWorld now needs a site — `createWorld(scene, { wind, site: await loadSite("backyard_01") })`. '
+ 'The yard stopped being code in SPRINT10; data/sites/*.json is the source of truth.'); })(),
);
const YARD = site.yard;
const GARDEN_BED = site.gardenBed;
const SUN_DIR = sunDirOf(site);
const HOUSE = site.house;
// 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 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 ?? [];
const root = new THREE.Group();
root.name = 'yard';
scene.add(root);
/** @type {THREE.Object3D[]} */
const solids = [];
/** @type {import('./contracts.js').Anchor[]} */
const anchors = [];
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
const canopies = [];
/** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */
const graybox = { house: null, trees: new Map(), bed: null };
/**
* E's three wilt states, siblings in one GLB — toggled by visibility rather
* than reloaded, which is why they all ship together.
* @type {{full: THREE.Object3D, tattered: THREE.Object3D, dead: THREE.Object3D}|null}
*/
let plants = null;
// --- sky & light -------------------------------------------------------
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
// "before" state the storm darkens away from.
scene.background = new THREE.Color(COLORS.sky);
scene.fog = new THREE.Fog(COLORS.sky, 34, 95);
// Sky fill carries more here than it would in most scenes. The sun sits in
// the NORTH (this is an Australian yard — "southerly change", gum trees), and
// the house is the yard's north edge, so the wall the player spends the whole
// game looking at is permanently backlit. That's correct, and it's also how
// a real south-facing rear wall looks — but the fascia line carries three of
// the seven anchors, so it has to stay readable in shadow rather than going
// to a void. Sky bounce is what does that in the real yard too.
const hemi = new THREE.HemisphereLight(0xbfd8ea, COLORS.grass, 1.8);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
sun.position.copy(SUN_DIR).multiplyScalar(40);
sun.target.position.set(0, 0, 0);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
// Frame the yard tightly — a loose shadow frustum is why sail shadows go
// soft and blocky, and the sail's shadow IS the product here.
const sc = sun.shadow.camera;
sc.left = -19; sc.right = 19; sc.top = 15; sc.bottom = -15;
sc.near = 1; sc.far = 90;
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.02;
scene.add(sun);
scene.add(sun.target);
// --- terrain -----------------------------------------------------------
const groundGeo = new THREE.PlaneGeometry(YARD.width, YARD.depth, 60, 40);
groundGeo.rotateX(-Math.PI / 2);
const gpos = groundGeo.attributes.position;
for (let i = 0; i < gpos.count; i++) {
gpos.setY(i, heightAt(gpos.getX(i), gpos.getZ(i)));
}
gpos.needsUpdate = true;
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(
groundGeo,
new THREE.MeshStandardMaterial({ color: COLORS.grass, roughness: 0.95 }),
);
ground.name = 'ground';
ground.receiveShadow = true;
root.add(ground);
// Deliberately NOT in `solids`: the ground is answered by heightAt(), which
// is exact and free. Raycasting it would be 4800 triangles a frame to learn
// something we already know in closed form.
// --- house (north edge) ------------------------------------------------
// 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
// should keep fascia_anchor_* at these positions.
//
// SPRINT14 — ONLY WHEN THE SITE DECLARES A HOUSE. This graybox used to be
// built unconditionally, and dress() only retires it when a house GLB loads,
// so a site with no `house` key kept it forever. site_02_corner_block has no
// 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
// `solids`, on the public URL. Found by opening the corner block in the yard
// editor and standing in it at eye height; nothing else in the repo looks
// north from inside that yard, which is why five sprints of green tests never
// 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(
new THREE.BoxGeometry(16.8, 0.22, 6.8),
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
);
roof.position.set(0, 3.1, -13);
roof.castShadow = true;
house.add(roof);
// The fascia line — the lie the player will be tempted by (DESIGN.md).
const fascia = new THREE.Mesh(
new THREE.BoxGeometry(16, 0.24, 0.12),
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
);
fascia.position.set(0, 2.72, -9.98);
house.add(fascia);
root.add(house);
solids.push(wall, roof);
graybox.house = house;
}
// 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
// matter and the IDS do — the site names them, and everything downstream
// (Lane B's quads, D's prompts, the balance suite) refers to them by name.
for (const [i, a] of (HOUSE?.anchors ?? []).entries()) {
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
{ work: a.work },
));
}
// --- trees -------------------------------------------------------------
// Two, on opposite shoulders, mirroring the prototype's tree placement.
// Canopies are separate named nodes so they can be swayed independently —
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
// (treeSpecs is site.trees — bound at the top of createWorld.)
for (const spec of treeSpecs) {
const y0 = heightAt(spec.x, spec.z);
const tree = new THREE.Group();
tree.name = `tree_${spec.id}`;
tree.position.set(spec.x, y0, spec.z);
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.18, 0.28, spec.trunkH, 8),
new THREE.MeshStandardMaterial({ color: COLORS.bark, roughness: 1 }),
);
trunk.name = 'trunk';
trunk.position.y = spec.trunkH / 2;
trunk.castShadow = true;
tree.add(trunk);
solids.push(trunk);
const canopy = new THREE.Group();
canopy.name = 'canopy';
canopy.position.y = spec.trunkH;
const blobMat = new THREE.MeshStandardMaterial({ color: COLORS.leaf, roughness: 1 });
for (const [j, b] of [
{ x: 0, y: 0.7, z: 0, r: 2.1 },
{ x: 1.1, y: 0.1, z: 0.5, r: 1.4 },
{ x: -0.9, y: 0.3, z: -0.6, r: 1.5 },
].entries()) {
const blob = new THREE.Mesh(new THREE.SphereGeometry(b.r, 12, 8), blobMat);
blob.name = `canopy_${j}`;
blob.position.set(b.x, b.y, b.z);
blob.castShadow = true;
canopy.add(blob);
}
tree.add(canopy);
canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });
root.add(tree);
graybox.trees.set(spec.id, tree);
// The anchor is at a branch fork, not the canopy centre. One per entry in
// the site's list; dress() moves each onto its baked branch_anchor_* node.
for (const a of spec.anchors ?? []) {
anchors.push(Object.assign(
makeSwayAnchor(a.id, new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z), spec.phase, wind),
{ work: a.work },
));
}
}
// --- posts -------------------------------------------------------------
// Raked away from the yard centre, because that is the correct practice and
// the shape should teach it before any text does (DESIGN.md: "rake the post
// away from the load").
// SPRINT3 decision 2: posts pulled in off the fence and a third added.
//
// The old pair sat at (-6, 7) and (5, 7.5), which put every rigging option in
// the 70192 m² range Lane B flagged — a sail that big pre-tensions itself
// into a cascade at t=0.4 s before the wind has done anything, so the yard was
// teaching the wrong lesson. Pulled in, plus p3, the same yard now offers 31
// quads in the 1845 m² band (8 of which shade a quarter of the bed or more).
//
// Worth knowing before anyone "fixes" it: the smallest quad that covers the
// bed COMPLETELY is 59 m², and that is not a bug to tune away. The bed sits
// 10 m off the house, so any house-to-post sail is ~16 m long, and covering a
// 6 m bed with it costs you a sail the storm will take. Full shade is meant to
// be the expensive answer; the small quads buy survival and pay in patchy
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
// position is measured rather than guessed — and the measurement moved with
// the data. p4's swept position table now lives in backyard_01.json's `_why`,
// beside the coordinate it justifies, because that is the number someone will
// be tempted to nudge. (postSpecs is site.posts, bound at the top.)
const RAKE = (8 * Math.PI) / 180;
for (const spec of postSpecs) {
const y0 = heightAt(spec.x, spec.z);
// Lean away from the centre of the yard, in the XZ plane.
const away = new THREE.Vector2(spec.x, spec.z).normalize();
const top = new THREE.Vector3(
spec.x + Math.sin(RAKE) * spec.h * away.x,
y0 + Math.cos(RAKE) * spec.h,
spec.z + Math.sin(RAKE) * spec.h * away.y,
);
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.06, 0.08, spec.h, 8),
new THREE.MeshStandardMaterial({ color: COLORS.steel, roughness: 0.5, metalness: 0.6 }),
);
post.name = `sail_post_${spec.id}`;
post.position.set((spec.x + top.x) / 2, (y0 + top.y) / 2, (spec.z + top.z) / 2);
post.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
top.clone().sub(new THREE.Vector3(spec.x, y0, spec.z)).normalize(),
);
post.castShadow = true;
root.add(post);
solids.push(post);
const footing = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.26, 0.18, 10),
new THREE.MeshStandardMaterial({ color: 0x9c9c96, roughness: 0.95 }),
);
footing.position.set(spec.x, y0 + 0.06, spec.z);
footing.receiveShadow = true;
root.add(footing);
anchors.push(Object.assign(
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
{ work: spec.work },
));
}
// --- structures (the carport, and whatever else a site tacks on) -------
// These carry ANCHORS, which is the whole point: site_02's carport looks like
// four free tie-offs and is the worst steel in the game. Graybox positions are
// approximate; dress() moves each anchor onto the node Lane E baked, with the
// rating_hint / collateral that make it a trap. The site's own asserts (E's
// e.test) pin those ratings below the fascia's — do not "fix" them upward.
const structures = new Map();
/**
* SPRINT12 — the house's collateral seam, taught rather than restructured.
* E named the trap precisely: collateralFor()/wreckStructure() iterate
* `structures`, and the house is NOT a structure — so the gutter stayed
* unpriced and unswappable. The alternative was moving the house into
* structures[] (it fits the shape), but the house is the one building with
* extra jobs — graybox stand-in, north boundary, the thing validateSite and
* two other lanes' code read at `site.house` — and re-homing it mid-sprint
* to avoid two small branches is churn, not design. If a third house-like
* thing ever appears, THEN generalise. `collateralKey` (E's field, baked in
* the GLB, canonical in site JSON) names which collateral string this
* price answers — the carport never needed it because there structure id
* and collateral string are the same word.
*/
const houseEntry = { spec: HOUSE, glb: null, wreck: null };
const houseKey = () =>
HOUSE ? (HOUSE.collateralKey ?? houseEntry.glb?.userData?.collateral_key ?? null) : null;
for (const st of site.structures ?? []) {
const marker = new THREE.Group();
marker.name = st.id;
marker.position.set(st.x, heightAt(st.x, st.z), st.z);
marker.rotation.y = rad(st.rotYDeg);
root.add(marker);
structures.set(st.id, { spec: st, marker });
for (const a of st.anchors ?? []) {
// Graybox guess: near the structure origin, above head height. dress()
// corrects it; until then the site is at least riggable in the selftest.
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
{ work: a.work },
));
}
}
// --- garden bed (the thing you are protecting) -------------------------
const bed = new THREE.Group();
bed.name = 'garden_bed';
const bedY = heightAt(GARDEN_BED.x, GARDEN_BED.z);
bed.position.set(GARDEN_BED.x, bedY, GARDEN_BED.z);
const soil = new THREE.Mesh(
new THREE.BoxGeometry(GARDEN_BED.w, 0.3, GARDEN_BED.d),
new THREE.MeshStandardMaterial({ color: COLORS.soil, roughness: 1 }),
);
soil.position.y = 0.15;
soil.receiveShadow = true;
bed.add(soil);
const plantMat = new THREE.MeshStandardMaterial({ color: COLORS.plant, roughness: 0.9 });
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 4; j++) {
const plant = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 6), plantMat);
plant.position.set(
(-0.5 + (i + 0.5) / 6) * GARDEN_BED.w,
0.42,
(-0.5 + (j + 0.5) / 4) * GARDEN_BED.d,
);
plant.castShadow = true;
plant.receiveShadow = true;
bed.add(plant);
}
}
root.add(bed);
graybox.bed = bed;
// --- boundary fence ----------------------------------------------------
// East, south and west only: the house is the north boundary.
const fence = new THREE.Group();
fence.name = 'fence';
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
const hx = YARD.width / 2, hz = YARD.depth / 2;
const SIDES = {
south: { from: [-hx, hz], to: [hx, hz] },
west: { from: [-hx, -hz], to: [-hx, hz] },
east: { from: [hx, -hz], to: [hx, hz] },
north: { from: [-hx, -hz], to: [hx, -hz] },
};
// backyard_01 fences south/west/east — the house IS its north boundary. The
// corner block has a street on two sides and fences the other two, which is
// the whole reason this is a site field rather than three hardcoded runs.
const runs = (site.fence?.sides ?? ['south', 'west', 'east']).map((s) => SIDES[s]).filter(Boolean);
for (const run of runs) {
const [x0, z0] = run.from, [x1, z1] = run.to;
const len = Math.hypot(x1 - x0, z1 - z0);
const n = Math.round(len / 2.5);
for (let i = 0; i <= n; i++) {
const f = i / n;
const x = x0 + (x1 - x0) * f, z = z0 + (z1 - z0) * f;
const p = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 0.1), railMat);
p.position.set(x, heightAt(x, z) + 0.8, z);
p.castShadow = true;
fence.add(p);
}
for (const ry of [0.6, 1.35]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.12, 0.04), railMat);
rail.position.set((x0 + x1) / 2, heightAt((x0 + x1) / 2, (z0 + z1) / 2) + ry, (z0 + z1) / 2);
rail.rotation.y = Math.atan2(-(z1 - z0), x1 - x0);
rail.castShadow = true;
fence.add(rail);
}
}
root.add(fence);
solids.push(fence);
// --- shed & spare table ------------------------------------------------
// Where the spare hardware lives, which makes it where the §7 scenario
// starts: rig → carry a spare → repair mid-storm. Lane D's pickup radius is
// 1.5 m off this point and everything downstream of it is already wired, so
// this small thing gates the whole hand-played loop.
//
// The position is published SYNCHRONOUSLY, from constants, even though the
// meshes arrive later in dress(). createWorld() has to stay sync — a.test.js
// and the selftest build a yard without a server — and Lane D's
// wireYardActions reads world.shedTable at wiring time. dress() refines the
// point to Lane E's baked `pickup_anchor` if it's there.
// 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),
} : null;
/**
* Show one of E's three wilt states. No-op against the graybox bed, so the
* HUD can call it unconditionally.
* @param {'full'|'tattered'|'dead'} which
*/
function setPlants(which) {
if (!plants) return;
for (const [k, node] of Object.entries(plants)) {
if (node) node.visible = k === which;
}
}
/**
* Swap Lane E's GLBs in over the graybox. Async and separate from
* createWorld() on purpose: the selftest builds a yard with no server, and a
* fetch in the constructor would either break it or make it slow and flaky.
* Every load is individually guarded — a missing GLB leaves its graybox
* standing rather than taking the boot down with it.
*/
async function dress() {
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
const loader = new GLTFLoader();
/** Take a graybox stand-in out of the scene AND out of `solids`. */
const retire = (obj) => {
if (!obj) return;
obj.traverse((o) => {
const i = solids.indexOf(o);
if (i >= 0) solids.splice(i, 1);
o.geometry?.dispose();
});
const i = solids.indexOf(obj);
if (i >= 0) solids.splice(i, 1);
obj.parent?.remove(obj);
};
/**
* Move an existing anchor onto the position Lane E baked, and take their
* rating_hint with it. Mutates `pos` in place rather than reassigning it:
* `interact.register` and Lane B's corners capture these vectors by
* reference, and a reassign would leave them holding a stale one.
*/
const adoptAnchor = (glb, nodeName, anchorId) => {
const node = glb.getObjectByName(nodeName);
const anchor = anchors.find((a) => a.id === anchorId);
if (!node || !anchor) return false;
anchor.pos.setFromMatrixPosition(node.matrixWorld);
anchor.ratingHint = node.userData?.rating_hint ?? 1;
anchor.collateral = node.userData?.collateral ?? null;
return true;
};
const load = async (name) => {
if (!name) return null; // the site simply doesn't have one
try {
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
gltf.scene.traverse((o) => {
if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
// Lane E's optional-node flag: glTF has no visibility bit and Blender's
// hide_render does NOT survive export (THREADS [E] 2026-07-17 — the
// garden bed drew all three wilt states superimposed for five sprints).
if (o.userData?.hidden_by_default) o.visible = false;
});
return gltf.scene;
} catch (err) {
console.warn(`[world] ${name} unavailable, keeping graybox:`, err.message);
return null;
}
};
// 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, 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)));
// --- garden bed ------------------------------------------------------
if (bedGlb) {
retire(graybox.bed);
bedGlb.name = 'garden_bed';
bedGlb.position.set(GARDEN_BED.x, heightAt(GARDEN_BED.x, GARDEN_BED.z), GARDEN_BED.z);
root.add(bedGlb);
const pick = (n) => bedGlb.getObjectByName(n) ?? null;
plants = { full: pick('plants_full'), tattered: pick('plants_tattered'), dead: pick('plants_dead') };
setPlants('full');
}
// --- gnome (aftermath collateral bait) -------------------------------
// Placed under the sail's likely footprint on purpose: DESIGN.md wants your
// own failures to be the worst debris, and something breakable has to be
// standing there for that to land.
if (gnome) {
gnome.name = 'garden_gnome_01';
gnome.position.set(GNOME.x, heightAt(GNOME.x, GNOME.z), GNOME.z);
gnome.rotation.y = GNOME.rotY;
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
// 4 m, which is a real part of why the yard now offers small quads at all.
// Every fascia anchor carries rating_hint 0.35 — E encoded DESIGN.md's
// "the fascia board is a lie" straight into the asset, and `collateral:
// "gutter"` says what it takes with it when it goes.
if (houseGlb) {
retire(graybox.house);
houseGlb.name = 'house_yardside';
houseGlb.position.set(HOUSE.x, heightAt(HOUSE.x, HOUSE.z), HOUSE.z);
root.add(houseGlb);
solids.push(houseGlb);
houseGlb.updateWorldMatrix(true, true);
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
houseEntry.glb = houseGlb;
// SPRINT12 — the torn-gutter wreck, the carport pattern on the one
// building that is not a structure. Loaded now and parked invisible for
// the same reason the carport's is: the swap happens as the storm ends,
// and an async load there drops the payoff frames after the card.
if (HOUSE.wreckedModel) {
const wreck = await load(HOUSE.wreckedModel);
if (wreck) {
wreck.name = 'house_yardside_wrecked';
wreck.position.copy(houseGlb.position);
wreck.visible = false;
root.add(wreck);
houseEntry.wreck = wreck;
}
}
}
// --- trees -----------------------------------------------------------
// Each tree ships 2-3 branch anchors with descending rating_hint (1.0 at
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
// nothing that already references them breaks; the rest are added.
for (const [i, spec] of treeSpecs.entries()) {
const glb = treeGlbs[i];
if (!glb) continue;
const old = graybox.trees.get(spec.id);
retire(old);
// The graybox canopy was what world.update() swayed — hand that job over.
const idx = canopies.findIndex((c) => old && old.getObjectByName('canopy') === c.group);
if (idx >= 0) canopies.splice(idx, 1);
glb.name = `tree_${spec.id}`;
glb.position.set(spec.x, heightAt(spec.x, spec.z), spec.z);
root.add(glb);
const trunk = glb.getObjectByName('trunk');
if (trunk) solids.push(trunk);
const canopy = glb.getObjectByName('canopy_01') || glb.getObjectByName('canopy');
if (canopy?.parent) {
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
}
// Every branch anchor the site names now EXISTS before dress() runs (they
// were created off spec.anchors above), so this only has to move them onto
// the nodes Lane E baked. That deleted the old suffix-guessing loop, which
// built t1/t1b/t1c out of an array index and could only ever describe the
// two trees it was written for.
glb.updateWorldMatrix(true, true);
for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
}
if (shed) {
shed.name = 'shed_01';
shed.position.set(SHED.x, heightAt(SHED.x, SHED.z), SHED.z);
shed.rotation.y = SHED.rotY;
root.add(shed);
solids.push(shed);
}
if (table) {
table.name = 'shed_table';
table.position.set(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z), SHED_TABLE.z);
table.rotation.y = SHED_TABLE.rotY;
root.add(table);
// NOT in solids: you want to walk up to the table, not be fenced off it.
// Prefer Lane E's baked anchor over my guess at where a table top is.
table.updateWorldMatrix(true, true);
const anchor = table.getObjectByName('pickup_anchor');
if (anchor) shedTable.pos.setFromMatrixPosition(anchor.matrixWorld);
}
// --- structures ------------------------------------------------------
// The carport, and its trap. Load the GLB, drop it on its graybox marker,
// and move each anchor onto the node E baked — carrying the rating_hint and
// collateral that make it the worst tie-off in the game. adoptAnchor reads
// both off userData, so the "0.22 beam" trap arrives with the geometry.
for (const st of site.structures ?? []) {
const entry = structures.get(st.id);
const glb = await load(st.model);
if (!glb) continue;
retire(entry.marker);
glb.name = st.id;
glb.position.set(st.x, heightAt(st.x, st.z), st.z);
glb.rotation.y = rad(st.rotYDeg);
root.add(glb);
if (st.solid) glb.traverse((o) => { if (o.isMesh) solids.push(o); });
glb.updateWorldMatrix(true, true);
for (const a of st.anchors ?? []) adoptAnchor(glb, a.node, a.id);
entry.glb = glb;
// SPRINT11 — the wreck, loaded NOW and parked invisible. E built it to the
// same origin and footprint so it swaps mesh-for-mesh (their e.test pins
// that, and that it carries the same price and names its intact twin).
// Loaded during dress rather than at the moment it's needed because the
// swap happens as the storm ends: an async load there would drop the
// payoff frames after the aftermath card, or not at all offline.
if (st.wreckedModel) {
const wreck = await load(st.wreckedModel);
if (wreck) {
wreck.name = `${st.id}_wrecked`;
wreck.position.copy(glb.position);
wreck.rotation.y = glb.rotation.y;
wreck.visible = false;
root.add(wreck);
entry.wreck = wreck;
}
}
}
return { shed, table };
}
// --- the world object --------------------------------------------------
return {
anchors,
heightAt,
gardenBed: GARDEN_BED,
sunDir: SUN_DIR.clone(),
solids,
root,
/**
* Where a spare gets picked up. `{pos}` — Lane D registers a 1.5 m hold-E
* off this point. Present from construction; dress() may nudge it onto
* Lane E's `pickup_anchor`.
*/
shedTable,
dress,
/** @param {'full'|'tattered'|'dead'} which */
setPlants,
/** Where the breakable client property stands, and what breaking it costs. */
gnome: GNOME,
// Lane C's skyfx MODULATES these as the storm builds and hands them back
// untouched on dispose() — it doesn't own them. That's why the yard exposes
// its lights rather than keeping them private.
sun,
hemi,
/** @param {string} id */
anchor(id) {
return anchors.find((a) => a.id === id) ?? null;
},
/**
* What the client's property costs you when your rig takes it with it.
*
* The key is the string E bakes as `collateral` on the anchor (site_02's
* carport anchors all say "carport"), so the chain is: tie to the beam →
* the beam's anchor carries `collateral:"carport"` → this prices it. The
* PRICE lives in site JSON, not the GLB: sites are data, and what a carport
* costs is this site's economy, not a property of the mesh. E's baked
* `collateral_value` is the fallback and their proposal (SPRINT11: 180,
* adopted with their reasoning).
*
* Returns null for an unpriced label, and that is deliberate rather than a
* zero: the house's fascia anchors carry `collateral:"gutter"` and NOBODY
* has priced a gutter yet. A missing price must read as "not scored", never
* as "free" — see THREADS, it's an open seam.
*
* @param {string} key
* @returns {{cost:number, label:string}|null}
*/
collateralFor(key) {
if (!key) return null;
for (const { spec, glb } of structures.values()) {
if (spec.id !== key) continue;
const cost = spec.collateralValue ?? glb?.userData?.collateral_value ?? null;
if (!Number.isFinite(cost)) return null;
return { cost, label: spec.collateralLabel ?? glb?.userData?.collateral_label ?? spec.id };
}
// SPRINT12 — the gutter (A's ruling: $90, E's proposal adopted; the
// reasoning is in backyard_01.json beside the number). Same contract as
// structures: site JSON canonical, GLB extras fallback, null when
// unpriced — a site whose house declares no collateralKey still reads
// "not scored", never "free".
if (HOUSE && key === houseKey()) {
const cost = HOUSE.collateralValue ?? houseEntry.glb?.userData?.collateral_value ?? null;
if (!Number.isFinite(cost)) return null;
return { cost, label: HOUSE.collateralLabel ?? houseEntry.glb?.userData?.collateral_label ?? key };
}
return null;
},
/**
* Swap a structure for its wreck. The payoff for the trap: you don't break a
* shackle, you take the carport, and the aftermath has to SHOW that.
* No-op (returns false) when the site declares no wreck or the GLB is
* missing — the graybox and headless paths still score the bill, they just
* can't show it. Idempotent.
* @param {string} id
*/
wreckStructure(id) {
const entry = structures.get(id);
if (entry?.wreck && entry.glb) {
entry.glb.visible = false;
entry.wreck.visible = true;
return true;
}
// The house, keyed by its collateral string ('gutter'): the gutter
// unzips, the house stands. E's window_glow warning honoured — the wreck
// ships its glow hidden_by_default under the same name, so the swap
// copies the intact house's current glow state or the client's lights
// would go out with the gutter if this ever lands mid-night.
if (HOUSE && id === houseKey() && houseEntry.wreck && houseEntry.glb) {
const glowWas = houseEntry.glb.getObjectByName('window_glow')?.visible ?? false;
const glow = houseEntry.wreck.getObjectByName('window_glow');
if (glow) glow.visible = glowWas;
houseEntry.glb.visible = false;
houseEntry.wreck.visible = true;
return true;
}
return false;
},
/** Is this structure standing? Lane D asked for a poke-able truth. */
isWrecked(id) {
if (HOUSE && id === houseKey()) return houseEntry.wreck?.visible === true;
return structures.get(id)?.wreck?.visible === true;
},
/**
* Take the whole yard out of the scene and free its GPU memory. SPRINT10:
* the week rebuilds the world when the site changes between nights, and
* without this each new yard leaks the last one's geometry and its lights
* pile up in the scene. Removes the root and both lights (skyfx borrows the
* lights and hands them back on its own dispose, so by here they're ours).
*/
dispose() {
root.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
root.parent?.remove(root);
sun.parent?.remove(sun);
sun.target.parent?.remove(sun.target);
hemi.parent?.remove(hemi);
},
update(dt, t) {
// Canopies lean with the wind they actually stand in — this is the tell
// the player reads a gust front from, a beat before it reaches the sail.
for (const c of canopies) {
const w = wind.sample(c.group.getWorldPosition(_worldPos), t);
const speed = w.length();
const lean = Math.min(0.22, speed * 0.007);
const flutter = 0.55 + 0.45 * Math.sin(t * 2.3 + c.phase);
c.group.rotation.z = c.base.z - Math.cos(Math.atan2(w.z, w.x)) * lean * flutter;
c.group.rotation.x = c.base.x + Math.sin(Math.atan2(w.z, w.x)) * lean * flutter;
}
},
};
}
const _worldPos = new THREE.Vector3();
/** @returns {import('./contracts.js').Anchor} */
function makeStaticAnchor(id, type, pos) {
const p = pos.clone();
return { id, type, pos: p, sway: () => p, ratingHint: DEFAULT_RATING_HINT };
}
/**
* A tree anchor. Wanders with the wind it stands in, which is exactly why it is
* the dangerous choice: the sway is dynamic load the rig has to eat, and a
* drum-tight rig on a swaying tree snaps turnbuckles (DESIGN.md).
*
* @returns {import('./contracts.js').Anchor}
*/
function makeSwayAnchor(id, pos, phase, wind) {
const p = pos.clone();
const scratch = new THREE.Vector3(); // per-anchor, so two anchors never alias
return {
id,
type: 'tree',
pos: p,
ratingHint: DEFAULT_RATING_HINT,
sway(t) {
const speed = wind.sample(p, t).length();
const amp = Math.min(0.35, speed * 0.012);
const s = Math.sin(t * 1.9 + phase);
return scratch.set(
p.x + s * amp,
p.y - Math.abs(s) * amp * 0.25,
p.z + Math.cos(t * 1.3 + phase) * amp * 0.5,
);
},
};
}