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:
parent
b98dcad13a
commit
0f5d69b32d
80
web/world/data/sites/site_02_corner_block.json
Normal file
80
web/world/data/sites/site_02_corner_block.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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>` : ''}
|
||||
|
||||
@ -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; },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; },
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user