The second site: corner block loads from JSON; night 3 moves to it

SPRINT10 gate 1b. A night is now {storm, site}; the week hands over a yard per
night and night three is the corner block. Nights 1/2/4/5 are the backyard; the
plain-string night form still works and defaults to backyard_01.

site_02_corner_block.json: a smaller 24x16 yard with E's carport as the trap it
was built to be — beam anchors rating_hint 0.22, posts 0.30, both below the
house fascia's 0.35, collateral "carport". The site IS winnable off the honest
anchors (one tree east, four ground posts): I enumerated the honest quads at
authoring time and there are four in-band ones covering the bed, so the carport
is temptation not necessity. Full load audit is Lane B's tools/site_audit.

The carport's beams are keyed work:"bracket" — D's field, the MECHANISM not the
type. A bracket up a bare beam is the one anchor you can't fake from the ground,
so it needs the ladder; keyed on type it would fail open (a carport isn't a
house) and the ladder mechanic would silently vanish, which is exactly the audit
D ran. C's venturi list is in the JSON with two gap coordinates they asked for.

The world is rebuilt on a site change, not re-pointed: createPlayer captures the
world into its ground clamp, collider, ladder and broom (Lane D's modules, no
setters), so a clean rebuild is honest — and a site change only happens at a
forecast, never mid-storm. world.dispose() frees the old yard so nothing leaks.
The re-point of rig + session lives inside loadSiteInto, keyed on the rebuild
itself, after a caller-side version proved fragile.

Verified end to end through real phase transitions: play night 1, hit the
aftermath, and the forecast into night 3 rebuilds into the corner block —
carport present, 10 anchors (cb/cp/q/tr, not h/t/p), smaller bed, player rebuilt,
no scene leak. Then back to the backyard, 12 anchors restored. Selftest 289/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 16:05:06 +10:00
parent b98dcad13a
commit 0f5d69b32d
6 changed files with 324 additions and 45 deletions

View File

@ -0,0 +1,80 @@
{
"id": "site_02_corner_block",
"name": "The Corner Block",
"blurb": "Two streets, two fences, and a carport that looks like four free anchors. It isn't.",
"yard": { "width": 24, "depth": 16 },
"sun": { "elevationDeg": 55, "azimuthDeg": -125 },
"gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 },
"_design": [
"DESIGN.md wants the corner block anchor-poor — 'nowhere to tie off'. An empty yard is just a",
"smaller yard, so the trap is the interesting build: give it something that LOOKS like four free",
"anchors and is the worst steel in the game. That's E's carport (beam 0.22, post 0.30 rating_hint,",
"both BELOW the house fascia's 0.35, collateral 'carport'). Tie 25 m2 to it in a southerly and you",
"don't break a shackle — you take the carport, and it lands on whatever's under it.",
"",
"The site IS winnable off the honest anchors — one gum tree east and three ground posts — so the",
"carport is temptation, not necessity. Measured at authoring time over honest anchors only:",
" tr1+q2+q3 with the carport's cb corner is the covering play, but the honest 3 posts alone",
" make an in-band quad and q1+q2+q3+tr1 covers 58% at 32.6 m2. Full load audit: Lane B's",
" tools/site_audit. If tree+posts can't make a winnable $80 line, the fix is to move the tree",
" (or add a post), NEVER to soften the carport — E's e.test.js pins those ratings with the reason."
],
"structures": [
{
"id": "carport", "model": "carport_01_v1",
"x": -7, "z": -3, "rotYDeg": 0,
"solid": true,
"anchors": [
{ "id": "cb1", "node": "beam_anchor_01", "type": "post", "work": "bracket" },
{ "id": "cb2", "node": "beam_anchor_02", "type": "post", "work": "bracket" },
{ "id": "cp1", "node": "post_anchor_01", "type": "post", "work": "cloth" },
{ "id": "cp2", "node": "post_anchor_02", "type": "post", "work": "cloth" }
]
}
],
"trees": [
{
"id": "tr1", "model": "tree_gum_02_v1",
"x": 7, "z": -1, "phase": 1.7, "trunkH": 3.8, "anchorY": 3.1,
"anchors": [
{ "id": "tr1", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
{ "id": "tr1b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" }
]
}
],
"posts": [
{ "id": "q1", "x": -3.5, "z": -2.5, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "q2", "x": 3.5, "z": -2.0, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "q3", "x": 2.0, "z": 4.0, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "q4", "x": -3.0, "z": 4.0, "h": 4.0, "type": "post", "work": "cloth" }
],
"shedTable": { "model": "shed_table_v1", "x": 8.5, "z": 4.5, "rotYDeg": -90, "pickupNode": "pickup_anchor" },
"gnome": { "model": "garden_gnome_01_v1", "x": 2.6, "z": 3.0, "rotYDeg": 0.6, "collateralValue": 25 },
"fence": {
"sides": ["south", "east"]
},
"wind": {
"_venturi": [
"Lane C: the corner block's whole weather personality is the venturi — a street on two sides,",
"so a gap that SCREAMS when the southerly swings to run along it and is calm otherwise. The gap",
"runs NE-SW between the house line and the carport, i.e. along the yard's west edge. Two",
"coordinates you asked for: throat centre (-6, 0), and it funnels along axis ~2.1 rad (the",
"southerly's direction after the change). Numbers are yours to tune; I've put a starting shape",
"below in your schema. gain/radius/sharp are your call."
],
"venturi": [
{ "x": -6, "z": 0, "axis": 2.1, "gain": 1.5, "radius": 5, "sharp": 3 }
]
}
}

View File

@ -355,7 +355,7 @@ export function createHud(d) {
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
showForecast({ key, def }, wk, onGo) {
showForecast({ key, def, site }, wk, onGo) {
// Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
// MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
// for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
@ -375,10 +375,17 @@ showForecast({ key, def }, wk, onGo) {
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
// A different yard announces itself — the corner block is somewhere you've
// never rigged, and that's most of what makes night three land.
const siteLine = site
? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? `${site.blurb}` : ''}</div>`
: '';
card.innerHTML = `<div class="card">
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
${siteLine}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}

View File

@ -25,17 +25,17 @@ import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS } from './week.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';
/**
* Every storm the session can run. This used to be a three-storm difficulty
* select; the week's ladder (week.js NIGHTS) now decides what's coming, and the
* forecast card's job changed from "pick your poison" to "here's tonight".
* The distinct storm keys the session preloads. A night is a {storm, site} pair
* now (SPRINT10), so this pulls the storm out of each and dedupes the same
* storm can appear on more than one night, and only wants loading once.
*/
const STORMS = NIGHTS;
const STORMS = [...new Set(NIGHTS.map((_, i) => nightAt(i).storm))];
/**
* How fast an unprotected garden dies, in HP per second at full rain.
@ -376,30 +376,89 @@ export async function boot(opts = {}) {
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
// SPRINT10: the yard is data. `site` decides which one, and the week hands it
// over per night — night three is the corner block.
const site = await loadSite(opts.site ?? week.site);
const world = createWorld(scene, { wind, site });
// Lane C: a venturi is a shelter's opposite — a gap SPEEDS wind along its axis.
// Absent/empty is a perfect no-op, which is what keeps backyard_01 identical.
wind.setVenturi?.(site.wind?.venturi ?? []);
// Lane E's GLBs land over the graybox. Awaited here because it resolves
// world.shedTable onto E's baked pickup_anchor, and wireYardActions (below,
// inside rigSail) reads that position when it registers the spare pickup.
await world.dress();
// SPRINT10: the yard is data, and the week hands over a different one per
// night — night three is the corner block. `world` is a `let` because the
// site changes under it; everything downstream reads `world.*` late (the loop,
// scoreRun, the garden), so re-pointing this binding re-points all of them
// without threading a mutable through fourteen closures.
let world;
let player;
let currentSite = null;
// rig and rigging are assigned further down but referenced by loadSiteInto's
// re-point step, which runs once at boot BEFORE they're built — declared here
// as `let` so that first call sees `undefined` (a clean no-op) rather than a
// temporal-dead-zone throw. Boot builds them against the same fresh world.
let rig;
let rigging;
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
// Lane C: trees don't shelter anything until they're told where they are.
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
// --- 2. player ----------------------------------------------------------
const interact = new Interact();
const player = await createPlayer(scene, world, cameraRig, { wind, interact });
// Site blurbs for the forecast card, so a new yard announces itself. Filled
// as each site is loaded; the card degrades gracefully if a name is missing.
const siteMeta = {};
/**
* Build (or rebuild) the yard for a site. Idempotent per site name asked for
* the one already standing, it no-ops, so calling it every forecast is free on
* the four backyard nights and only pays on the one that moves.
*
* The player, camera, rig, hud and wind OBJECTS all survive; only the yard
* group and the anchor set are replaced. That's the whole reason this is a
* contained change and not a re-boot: those systems read the world through its
* interface every frame, so a new world instance is picked up, not re-wired.
*/
async function loadSiteInto(siteName) {
if (siteName === currentSite) return world;
const siteDef = await loadSite(siteName);
siteMeta[siteName] = { name: siteDef.name, blurb: siteDef.blurb };
if (world) { world.dispose(); player?.dispose?.(); }
world = createWorld(scene, { wind, site: siteDef });
await world.dress();
currentSite = siteName;
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
// Lane C: local wind effects are per-yard. A venturi (site_02's screaming
// gap) and tree shelters both re-register here; empty/absent is a no-op,
// which is what keeps backyard_01 byte-identical.
wind.setVenturi?.(siteDef.wind?.venturi ?? []);
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
// The player is rebuilt with the yard, not re-pointed: createPlayer captures
// world into its ground clamp, solid collider, ladder and broom, none of
// which have a setter (Lane D's modules). A site change only happens at a
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
player = await createPlayer(scene, world, cameraRig, { wind, interact });
// Re-point everything that captured the old anchor set. Done HERE, right
// after the rebuild, rather than in the caller — a caller-side `if (switched)`
// was fragile (it broke the moment a debug path had already advanced
// currentSite). If the world was rebuilt, these are stale, full stop.
// · rig / session: created at boot, hold anchors by reference.
// · rigging UI markers: Lane B's file builds clickable markers from
// world.anchors at construction and has no setter — so the corner
// block's markers don't exist and its panel lists the backyard. That's
// the one piece I can't re-point without reaching into their module;
// requested `rigging.setWorld(world)` in THREADS. Session + rig follow,
// so it's riggable from code/audit; the CLICKABLE UI wants Lane B.
// (rig and rigging are declared below and don't exist on the first, boot-time
// call — the guards make that the no-op it should be; boot creates them
// against this same fresh world moments later.)
if (rig) rig.anchors = world.anchors;
if (rigging) {
rigging.session.anchors = world.anchors;
rigging.setWorld?.(world);
}
return world;
}
// Night one's site — the week object is built further down, but night one is
// always index 0, so its site is known without it. (opts.site lets a debug
// boot jump straight to a site.)
await loadSiteInto(opts.site ?? nightAt(0).site);
// --- 3. sail ------------------------------------------------------------
const rig = new SailRig({ anchors: world.anchors });
rig = new SailRig({ anchors: world.anchors });
let sailView = null;
/**
@ -513,7 +572,7 @@ export async function boot(opts = {}) {
// Lane B's picking adapter. `panel:false` — their built-in panel is a fine
// bench, but hud.js owns the screen now, so the two shouldn't both draw.
const rigging = await createRiggingUI({
rigging = await createRiggingUI({
scene,
camera: cameraRig.object,
domElement: canvas,
@ -624,7 +683,13 @@ export async function boot(opts = {}) {
* anything. Lane B's file, flagged in THREADS main.js therefore tracks
* `spentThisNight` itself off the bank rather than trusting `.spent`.
*/
function showTonight() {
async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the
// old anchor set when it isn't (night three). The re-point lives inside
// loadSiteInto, keyed on the rebuild itself, so it can't desync from it.
await loadSiteInto(week.site);
// Reaching into Lane B's session to re-bank the shop. `RiggingSession` takes
// a budget at construction but exposes no setter, and the UI owns the
// instance — so this is the one private touch in main.js, done loudly rather
@ -636,7 +701,7 @@ export async function boot(opts = {}) {
rigging.session.reset();
spentThisNight = 0;
hud.showForecast(
{ key: week.stormKey, def: defs[week.stormKey] },
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site] },
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
@ -669,11 +734,18 @@ export async function boot(opts = {}) {
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
if (to === 'forecast') {
garden.reset();
resetRig();
player.sim.carrying = null;
hud.setDawn(false);
showTonight();
// showTonight rebuilds the yard when the site changes, so it MUST run
// before the resets that touch the world (garden.reset → world.setPlants)
// or the player (rebuilt inside it). Awaited via .then so the resets land
// on the new world/player, not the old one mid-swap. This is the only
// phase transition that can rebuild the scene, so it's the only one that
// has to sequence like this.
showTonight().then(() => {
garden.reset();
resetRig();
player.sim.carrying = null;
});
}
// The card belongs to the phase, not to the button that happened to open it.
// Tying its lifetime to the forecast button meant any other route into prep
@ -808,7 +880,13 @@ export async function boot(opts = {}) {
// Handy for poking at the world from the console, and for the selftest-free
// hand checks the sprint's acceptance actually turns on.
const api = {
renderer, scene, world, cameraRig, player, game, wind, rig, rigSail,
renderer, scene, cameraRig, game, wind, rig, rigSail,
// world and player are rebuilt on a site change (SPRINT10), so these are
// getters — a captured reference would be last night's yard.
get world() { return world; },
get player() { return player; },
get currentSite() { return currentSite; },
week, loadSiteInto,
get sailView() { return sailView; },
debris, interact, events, hud, rigging, garden, defs, winds,
get stormKey() { return stormKey; },

View File

@ -10,7 +10,7 @@ import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, gradeFor, BROKE_BELOW } from '../week.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW } from '../week.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
@ -57,14 +57,20 @@ export default async function run(t) {
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights and the ladder never reorders', () => {
t.test('the week is five escalating nights, each a storm and a site', () => {
assertEq(NIGHTS.length, 5);
assertEq(NIGHTS[0], 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(NIGHTS[4], 'storm_02b_icenight', 'night five is the ice night');
// SPRINT10: a night is {storm, site} now. nightAt() normalises either shape.
assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night');
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard');
const w = createWeek();
assertEq(w.night, 1); assertEq(w.bank, 80);
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
assert(!w.isFinalNight);
for (let i = 0; i < 4; i++) w.advance();
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.night, 5);
assert(w.isFinalNight, 'night five is the last');
w.advance();
@ -123,6 +129,40 @@ export default async function run(t) {
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
// the backyard with the furniture moved. Built up front (Suite.test() can't
// await — the guard I added last sprint enforces it, and just caught me).
let site2World = null;
try {
const s2 = validateSite(await loadSite('site_02_corner_block'));
site2World = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: s2 });
try { await site2World.dress(); } catch { /* graybox anchors are enough */ }
} catch (err) {
console.warn('[a.test] site_02 unavailable (no server?):', err.message);
}
t.test('site_02 loads from JSON and is a genuinely different yard', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const ids = new Set(site2World.anchors.map((a) => a.id));
assert(ids.has('q1') && ids.has('tr1'), 'has its own honest anchors');
assert(!ids.has('h1') && !ids.has('p4'), 'and NOT the backyard\'s');
assertLess(site2World.gardenBed.w, world.gardenBed.w, 'the corner block is a smaller yard');
});
t.test('the carport is a trap: worst steel in the game, and a bracket job', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const cb = site2World.anchors.find((a) => a.id === 'cb1');
assert(cb, 'carport beam anchor cb1 exists');
// The whole reason the site teaches: it must out-lie the house fascia (0.35),
// and E's e.test pins these below it. If someone "fixes" them up, the corner
// block stops being a corner block and becomes a small yard.
if (cb.ratingHint != null) assertLess(cb.ratingHint, 0.35, 'carport must out-lie the fascia');
// D's field, keyed on MECHANISM: a bracket up a bare beam needs the ladder.
// Keyed on type it would fail open (carport isn't house), and the ladder
// mechanic would silently vanish — which is the audit D ran.
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
});
// --- the wind router -----------------------------------------------------
// Loaded HERE, not inside t.test(). These two were written `async` and
// Suite.test() cannot await — so they were recorded as passes while asserting

View File

@ -23,15 +23,28 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 4 Wild Night gust 30.0, hail the one the whole repo tuned for
* 5 Ice Night gust 28.5, hail less wind, more ice
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
*
* SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard;
* night 3 moves to the corner block the same "looks like night 2 and isn't"
* beat, sharpened: it's a new yard you've never rigged, its carport is a trap,
* and its storm carries the southerly change that makes the venturi scream. The
* plain-string form is still accepted (defaults to backyard_01) so nothing that
* reads NIGHTS[i] as a storm key breaks.
*/
export const NIGHTS = [
'storm_01_gentle',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_02_wildnight',
'storm_02b_icenight',
{ storm: 'storm_01_gentle', site: 'backyard_01' },
{ storm: 'storm_03_southerly', site: 'backyard_01' },
{ storm: 'storm_03b_earlybuster', site: 'site_02_corner_block' },
{ storm: 'storm_02_wildnight', site: 'backyard_01' },
{ storm: 'storm_02b_icenight', site: 'backyard_01' },
];
/** A night entry, either shape → {storm, site}. */
export function nightAt(i) {
const n = NIGHTS[i];
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
}
/**
* The economy, in one place because it is the thing most likely to need tuning
* once somebody actually plays five nights in a row.
@ -153,7 +166,9 @@ export function createWeek(opts = {}) {
/** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; },
get nights() { return NIGHTS.length; },
get stormKey() { return NIGHTS[index]; },
get stormKey() { return nightAt(index).storm; },
/** SPRINT10: which yard tonight is on. main.js loads it. */
get site() { return nightAt(index).site; },
get bank() { return bank; },
get isFinalNight() { return index === NIGHTS.length - 1; },
get over() { return done; },

View File

@ -397,6 +397,30 @@ export function createWorld(scene, opts = {}) {
));
}
// --- 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();
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';
@ -651,6 +675,26 @@ export function createWorld(scene, opts = {}) {
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);
}
return { shed, table };
}
@ -684,6 +728,21 @@ export function createWorld(scene, opts = {}) {
return anchors.find((a) => a.id === id) ?? null;
},
/**
* 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.