From 0f5d69b32de64d40a886f656c0ae4968c37b65e1 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 16:05:06 +1000 Subject: [PATCH] The second site: corner block loads from JSON; night 3 moves to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../data/sites/site_02_corner_block.json | 80 ++++++++++ web/world/js/hud.js | 9 +- web/world/js/main.js | 144 ++++++++++++++---- web/world/js/tests/a.test.js | 50 +++++- web/world/js/week.js | 27 +++- web/world/js/world.js | 59 +++++++ 6 files changed, 324 insertions(+), 45 deletions(-) create mode 100644 web/world/data/sites/site_02_corner_block.json 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.