diff --git a/web/world/data/sites/site_02_corner_block.json b/web/world/data/sites/site_02_corner_block.json
new file mode 100644
index 0000000..3cd7ae7
--- /dev/null
+++ b/web/world/data/sites/site_02_corner_block.json
@@ -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 }
+ ]
+ }
+}
diff --git a/web/world/js/hud.js b/web/world/js/hud.js
index b075f9a..6a2c77e 100644
--- a/web/world/js/hud.js
+++ b/web/world/js/hud.js
@@ -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 ? '●' : '○') : '·'}`;
}).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
+ ? `
${site.name}${site.blurb ? ` — ${site.blurb}` : ''}
`
+ : '';
+
card.innerHTML = `
NIGHT ${wk.night} OF ${wk.nights}
${pips}
${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}
+ ${siteLine}
${f.wind}
${f.rain}${hail ? ' · hail forecast' : ''}
${f.confidence ? `
${f.confidence}
` : ''}
diff --git a/web/world/js/main.js b/web/world/js/main.js
index f67b863..67f5faf 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -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; },
diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js
index 5c6a510..6bf7494 100644
--- a/web/world/js/tests/a.test.js
+++ b/web/world/js/tests/a.test.js
@@ -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
diff --git a/web/world/js/week.js b/web/world/js/week.js
index 0e69cf4..a47d65c 100644
--- a/web/world/js/week.js
+++ b/web/world/js/week.js
@@ -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; },
diff --git a/web/world/js/world.js b/web/world/js/world.js
index 2815dff..63cc618 100644
--- a/web/world/js/world.js
+++ b/web/world/js/world.js
@@ -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.