/**
 * SHADES — boot, game loop, phase machine. Lane A owns this file.
 *
 * This is the assembly point: every other lane's module is proven in isolation,
 * and this file is where they become one game. Two rules make that possible and
 * are worth not breaking:
 *
 *  - **Nothing auto-runs on import.** index.html calls boot(). That keeps
 *    createGame() importable from selftest.html, which must never construct a
 *    WebGLRenderer.
 *  - **The loop is a fixed-dt accumulator.** Sim modules only ever see FIXED_DT,
 *    never a real frame delta. That is the whole reason selftest can fast-forward
 *    a 90 s storm in milliseconds and get the numbers the player got.
 */

import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld, loadSite } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
import { SailRig, createSailView } from './sail.js';
import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js';

/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';

/**
 * 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 = [...new Set(NIGHTS.map((_, i) => nightAt(i).storm))];

/**
 * How fast an unprotected garden dies, in HP per second at full rain.
 *
 * Decision 7: drain is rain × (1 − rain shadow), NOT sun coverage. At night the
 * sun shadow is a number about nothing, and storm_02 is a wildnight.
 *
 * ⚠️ This number is doing less work than it looks like it is, and the reason is
 * worth reading before retuning it. Measured over storm_02 with a rig that holds
 * 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN
 * shadow decays 0.38 → 0.04 as the wind builds, averaging 0.23. Driving rain
 * blows under the sail and the shadow walks off the bed — which is Lane C's
 * model being right about weather, not a bug.
 *
 * The consequence is that a perfectly rigged bed only ever sits ~23% drier than
 * a bare one, so NO value here separates good rigging from none; at 1.6 both
 * ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to
 * put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so
 * the loop is playable and scores something — but the lever that actually needs
 * moving is the shadow geometry, not this. Flagged for Lane C in THREADS.
 */
const GARDEN_DRAIN = 0.9;

/**
 * Decision 13's weights. Hail is what actually kills a garden and cloth honestly
 * stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks
 * straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of
 * damage so the night still costs you something when it isn't hailing.
 *
 * SPRINT6 gate 1 lists these first among the balance levers.
 */
const HAIL_WEIGHT = 5.0;
const RAIN_WEIGHT = 0.25;

/**
 * The garden: the thing you are actually protecting, and the only score that
 * matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide.
 *
 * It keeps its damage split by CAUSE, and that isn't bookkeeping for its own
 * sake: the aftermath verdict is the game's entire feedback channel, and a
 * verdict that guesses teaches the wrong lesson. A garden that only knows it
 * went from 100 to 39 cannot tell a player whether their hardware let go or
 * their perfectly-held sail simply wasn't over the bed — and those are opposite
 * mistakes with opposite fixes.
 */
function createGarden(world) {
  let hp = 100;
  let state = 'full';
  let byHail = 0;
  let byRain = 0;

  return {
    get hp() { return hp; },
    get state() { return state; },
    /** HP lost to each cause this run. The verdict reads these. */
    get damage() { return { hail: byHail, rain: byRain }; },

    reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); },

    /**
     * Decision 13, and the two terms stay SEPARATE on the way in rather than
     * being pre-summed by the caller — that sum is exactly the information the
     * verdict needs and can never recover afterwards.
     *
     * @param {number} dt
     * @param {number} hail 0..1  sky.gardenHailExposure(bed, t)
     * @param {number} rain 0..1  sky.gardenExposure(bed, t)
     */
    step(dt, hail, rain) {
      const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt;
      const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt;
      const total = Math.min(hp, dHail + dRain);
      if (total > 0) {
        // Attribute proportionally, so the split still adds up on the last tick
        // when the garden bottoms out at 0 mid-step.
        const scale = total / (dHail + dRain);
        byHail += dHail * scale;
        byRain += dRain * scale;
        hp -= total;
      }
      const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
      if (next !== state) { state = next; world.setPlants(next); }
    },
  };
}

/**
 * Why the night went the way it did — read from what ACTUALLY happened.
 *
 * This is the whole of the game's feedback channel, and it was lying: any run
 * ending under 50 read "the rain found what you skimped on", including a run
 * that held 4/4 corners and skimped on nothing. That is not a typo-grade bug.
 * DESIGN.md wants every disaster to replay in the player's head as "…the
 * shackle, I knew about the shackle" — a verdict that blames the wrong thing
 * teaches the opposite of the lesson the storm just spent 90 seconds giving.
 *
 * So: pick from the failure modes the run can prove, in the order the player
 * most needs to hear them. The two garden losses are deliberately different
 * sentences, because they are opposite mistakes — hardware you under-bought
 * versus a rig that held perfectly and simply wasn't over the bed.
 *
 * @returns {{verdict: string, mode: string}}
 */
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }) {
  const worst = lost.length
    ? lost.reduce((a, c) => (a && a.hw.rating <= c.hw.rating ? a : c))
    : null;
  const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
  const hailKilled = dmg.hail > dmg.rain;

  // SPRINT9 decision 1. This case had to come FIRST because it did not used to
  // exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the
  // new rule it can be the best night the game has. A sail that tore itself
  // apart while the bed came through is DESIGN.md's actual story — the firework
  // you paid for — and the aftermath's hardware bill is where that payment goes.
  if (win && lost.length >= 2) {
    return { mode: 'pyrrhic',
      verdict: `THE GARDEN MADE IT. The sail didn't — the ${named} went first and took `
        + `${lost.length - 1} more with it. That is what the sail was for.` };
  }
  if (!win && lost.length >= 2) {
    return { mode: 'cascade',
      verdict: `THE SAIL LOST. The ${named} went first — and took ${lost.length - 1} more with it.` };
  }
  if (lost.length === 1 && !win) {
    return { mode: 'corner',
      verdict: `THE SAIL LOST. One corner short: the ${named} let go.` };
  }
  if (!win && hailKilled) {
    // The lesson the old verdict destroyed. Every corner held; the rig was
    // simply not over the thing it was paid to protect.
    return { mode: 'uncovered',
      verdict: 'THE GARDEN IS GONE — and every corner held. The hail fell where your sail wasn\'t.' };
  }
  if (!win) {
    return { mode: 'rain',
      verdict: 'THE GARDEN IS GONE. Not dramatic — just a long night of rain on open ground.' };
  }
  if (pondDumped > 50) {
    return { mode: 'broomed',
      verdict: `THE GARDEN MADE IT. You put ${Math.round(pondDumped)} kg of water on your own head to do it.` };
  }
  if (pondPeak > 80) {
    return { mode: 'ponded',
      verdict: 'THE GARDEN MADE IT — with a belly full of water. That flat spot will find you.' };
  }
  if (lost.length === 1) {
    return { mode: 'held-one-down',
      verdict: `THE GARDEN MADE IT. The ${named} let go and the rest carried it.` };
  }
  if (hp >= 85) {
    return { mode: 'clean', verdict: 'THE GARDEN MADE IT — not a leaf out of place.' };
  }
  return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
}

// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------

/**
 * forecast → prep → storm → aftermath → forecast.
 * @returns {import('./contracts.js').Game}
 */
export function createGame() {
  const emitter = new Emitter();
  let phase = 'forecast';
  let phaseT = 0;

  return {
    get phase() { return phase; },
    /** Seconds since this phase began. The storm clock. */
    get phaseT() { return phaseT; },

    on(type, fn) { return emitter.on(type, fn); },

    /** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
    setPhase(next) {
      if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
      if (next === phase) return;
      const from = phase;
      phase = next;
      phaseT = 0;
      emitter.emit('phaseChange', { from, to: next });
    },

    /** Advance to the next phase in loop order. */
    advance() {
      this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]);
    },

    tick(dt) {
      phaseT += dt;
      // The storm is on a timer; every other phase waits for the player.
      if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath');
    },
  };
}

// ---------------------------------------------------------------------------
// Wind router
// ---------------------------------------------------------------------------

/**
 * One wind object whose identity never changes, delegating to whichever storm
 * is currently running.
 *
 * Every consumer binds to wind exactly once, at construction — the yard closes
 * over it for tree sway, createPlayer takes it in opts, createDebris reads its
 * event stream. So swapping storm_01 for storm_02 at the phase change has to be
 * a re-point, not a re-wire, or half the game would still be sampling the calm
 * day while the other half is in a gale.
 *
 * Shelters are applied to every storm rather than just the active one: they
 * describe the yard's trees, which don't stop existing when the weather turns.
 *
 * ⚠️ **This delegation list is hand-maintained, and that has already cost us
 * once.** When Lane C added rainMmPerHour/rainDepthMm for ponding, this router
 * didn't forward them: every test still passed, because tests hold a real wind
 * while only the GAME holds the router — so B's ponding would have been green
 * across the board and done nothing in the actual yard. The integrator caught it
 * by hand at merge.
 *
 * Anything new on the wind contract must be added here too. `js/tests/a.test.js`
 * has a tripwire that diffs this object against a real wind and fails naming
 * whatever is missing, so the next omission is a red test rather than a system
 * that silently isn't plugged in.
 *
 * @param {object[]} all  every wind this session can switch between
 */
export function createWindRouter(all) {
  let active = all[0];

  const router = {
    /** The wind currently in force. Assign through use(). */
    get active() { return active; },
    use(w) { active = w; return router; },

    sample: (pos, t, out) => active.sample(pos, t, out),
    speedAt: (pos, t) => active.speedAt(pos, t),
    gustTelegraph: (t) => active.gustTelegraph(t),
    eventsBetween: (a, b) => active.eventsBetween(a, b),
    rainAt: (t) => active.rainAt(t),
    rainMmPerHour: (t) => active.rainMmPerHour(t),
    rainDepthMm: (a, b) => active.rainDepthMm(a, b),
    hailAt: (t) => active.hailAt(t),                 // SPRINT5 decision 13 — the garden score hangs off this
    dirAt: (t) => active.dirAt(t),

    setShelters(list) {
      for (const w of all) w.setShelters(list);
      return router;
    },
    setSheltersFromTrees(trees, o = {}) {
      return router.setShelters(trees.map((tr) => ({
        x: tr.pos ? tr.pos.x : tr.x,
        z: tr.pos ? tr.pos.z : tr.z,
        radius: o.radius ?? tr.radius ?? 3,
        strength: o.strength ?? 0.45,
        length: o.length ?? 14,
      })));
    },
    setVenturi(list) {                               // SPRINT9 site_02 — funnels are site geometry
      for (const w of all) w.setVenturi(list);
      return router;
    },
    get venturi() { return active.venturi; },

    get hailSize() { return active.hailSize; },      // SPRINT5 decision 13
    get duration() { return active.duration; },
    get gusts() { return active.gusts; },
    get def() { return active.def; },
    get seed() { return active.seed; },
    get core() { return active.core; },
  };
  return router;
}

// ---------------------------------------------------------------------------
// Debris models
// ---------------------------------------------------------------------------

/**
 * Lane E's crates and tubs, keyed by the names storm JSON spawns and debris.js
 * has radii for. A browser can't glob a directory, so the list is explicit —
 * and it should stay matched to MODEL_SPEC in debris.js (Lane C's ask: tell them
 * rather than fighting the radii).
 *
 * Missing files are not fatal: debris.js falls back to a graybox box per piece,
 * which is exactly the degrade-quietly behaviour Lane C designed for.
 */
const DEBRIS_MODELS = ['BlueCrate_v2', 'BlackTub_v2', 'WhiteTub_v2', 'WoodenBin_v2'];

async function loadDebrisModels() {
  const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
  const loader = new GLTFLoader();
  const out = {};
  await Promise.all(DEBRIS_MODELS.map(async (name) => {
    try {
      const gltf = await loader.loadAsync(`./models/debris/${name}.glb`);
      gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
      out[name] = gltf.scene;
    } catch (err) {
      console.warn(`[main] debris model ${name} unavailable, using graybox:`, err.message);
    }
  }));
  return out;
}

// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------

/**
 * @param {object} [opts]
 * @param {HTMLCanvasElement} [opts.canvas]
 */
export async function boot(opts = {}) {
  const canvas = opts.canvas ?? document.getElementById('c');

  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;

  const scene = new THREE.Scene();

  // --- 1. weather ---------------------------------------------------------
  // Every storm loads up front: the forecast card has to read their shapes to
  // sell them before the player has agreed to face one.
  const defs = Object.fromEntries(
    await Promise.all(STORMS.map(async (k) => [k, await loadStorm(k)])),
  );
  const winds = Object.fromEntries(Object.entries(defs).map(([k, def]) => [k, createWind(def)]));
  const calmWind = winds[CALM_STORM];
  let stormKey = 'storm_02_wildnight';
  const wind = createWindRouter(Object.values(winds));

  // --- world & camera -----------------------------------------------------
  // 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);
  const interact = new 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 ------------------------------------------------------------
  rig = new SailRig({ anchors: world.anchors });
  let sailView = null;

  /**
   * Attach the cloth across 4 anchors and (re)build its view.
   *
   * The order here is load-bearing. createSailView reads rig.pos and rig.tris,
   * which don't exist until attach() allocates them in _build() — build the view
   * first and it throws on an undefined array. A re-rig can also change the grid,
   * so the view has to be rebuilt rather than reused. Both facts make this the
   * single door that boot and Lane B's picking adapter should come through.
   *
   * Re-wiring interact each time is deliberate: its targets close over corner
   * objects and attach() makes a fresh corners array, so stale closures would
   * point at corners the sim no longer steps. The ids are stable, so this
   * replaces the old targets rather than stacking duplicates.
   */
  async function rigSail(anchorIds, hwChoices, tension = 1.0) {
    rig.attach(anchorIds, hwChoices, tension);
    if (sailView) {
      scene.remove(sailView);
      sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
    }
    sailView = await createSailView(rig);
    scene.add(sailView);
    wireYardActions(interact, { sailRig: rig, world });
    return sailView;
  }

  // Until Lane B's prep-phase picking adapter lands (SPRINT2 §B.3), rig a
  // default quad so the yard has a live sail and Lane D has something to
  // repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner
  // corner. It also spans most of the yard, which is the 70–192 m² problem
  // decision 2 fixes in step 6, not a fault in the cloth.
  const game = createGame();
  const garden = createGarden(world);

  // --- clocks -------------------------------------------------------------
  // Two of them, and the distinction matters. `simT` is wall-clock seconds since
  // boot. `windT` is STORM time — storm JSON is authored with t=0 at the storm's
  // first gust, so it's phase time during the storm, and off-storm it wraps the
  // calm day around its own duration so the breeze keeps breathing however long
  // you spend rigging. Every sim module samples windT; nothing samples simT.
  let simT = 0;
  let windT = 0;
  let acc = 0;

  function windTime() {
    if (game.phase === 'storm') return game.phaseT;
    return simT % Math.max(1, calmWind.duration);
  }

  // --- 4. sky, audio, debris ---------------------------------------------
  const events = [];
  const pushEvent = (text) => {
    events.push({ t: game.phaseT, text });
    if (events.length > 4) events.shift();
  };

  const debris = createDebris({
    wind,
    scene,
    heightAt: world.heightAt,
    // knockdown(t, dirX, dirZ) — the first arg is the sim clock, NOT the impact
    // magnitude. Passing `impact` here would jam ~40 into the state machine's
    // start time and the player would never get up. The piece's own velocity is
    // the direction, so you fall the way the crate was travelling.
    onHitPlayer: (piece) => player.sim.knockdown(windT, piece.vx, piece.vz),
    onEvent: pushEvent,
  });
  debris.setModels(await loadDebrisModels());

  // skyfx reads the storm's `sky` block at construction (darkness, cloud scroll,
  // night), so it is rebuilt when the storm changes rather than re-pointed like
  // wind. dispose() hands world.sun/world.hemi back exactly as they were, which
  // is what makes that safe to do mid-session.
  let sky = null;
  let audioUnlocked = false;
  function makeSky() {
    if (sky) sky.dispose();
    sky = createSkyFx({
      scene,
      camera: cameraRig.object,
      wind,
      sun: world.sun,
      hemi: world.hemi,
      onEvent: pushEvent,
    });
    if (audioUnlocked) sky.unlockAudio();
    return sky;
  }
  makeSky();

  // Browsers won't start an AudioContext without a gesture. Without this the
  // storm is silent, and half of DESIGN.md's threat model is audible.
  const unlock = () => {
    if (audioUnlocked) return;
    audioUnlocked = true;
    sky?.unlockAudio();
    removeEventListener('pointerdown', unlock);
    removeEventListener('keydown', unlock);
  };
  addEventListener('pointerdown', unlock);
  addEventListener('keydown', unlock);

  // --- 5. the face --------------------------------------------------------
  const banner = document.getElementById('banner');
  const hud = createHud({
    scene, camera: cameraRig.object, game, world, wind, player, rig, garden, events,
    getSky: () => sky,
  });

  // 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.
  rigging = await createRiggingUI({
    scene,
    camera: cameraRig.object,
    domElement: canvas,
    world,
    onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); },
    onMessage: pushEvent,
    panel: true,
  });

  /**
   * Clear last round's rig so "play again" is a new job, not a continuation.
   *
   * Without this you inherit the previous round's corners AND its spent budget —
   * a second round starts at $35 with three corners already hung, and the four
   * anchors you click get silently ignored because the session is already full.
   * That is not a subtle failure; it makes the second round unplayable.
   *
   * Done through the session's own public moves rather than by reaching into its
   * fields: unrig() refunds the corner's CURRENT hardware and setSpares(0)
   * refunds the spare, so the budget walks back to $80 on its own and the
   * economy stays the single source of truth. (Lane B: a `session.reset()` would
   * say this better than five lines of mine — asked in THREADS.)
   */
  function resetRig() {
    const s = rigging.session;
    for (const p of [...s.picks]) s.unrig(p.anchorId);
    s.setSpares(0);
    s.setTension(1.0);
  }

  /** What the storm cost, assembled at the moment it ends. */
  function scoreRun() {
    const lost = rig.corners.filter((c) => c.broken);
    const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
    const collateral = [];
    // The gnome is collateral if the sail came down over it. DESIGN.md: the
    // worst debris in any storm is your own failed work.
    if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
    const s = rigging.summary;
    const hp = garden.hp;
    /**
     * SPRINT9 decision 1 — Lane A's ruling on the pyrrhic win. It was
     * `hp >= 50 && lost.length < 2`; the corner clause is gone.
     *
     * **The garden is the client's. The sail is yours.** Saving the bed with a
     * rig that tore itself apart is the job done expensively — not a failure.
     * DESIGN.md doesn't call a cascade a loss, it calls it "a firework you paid
     * for", and *paid for* is the whole point: the aftermath already bills you
     * for the broken hardware, the collateral, and a week's fee you didn't earn
     * cleanly. Gating the win on corners bills you twice for the same night and
     * then lies about it — which is the same species as the verdict that used to
     * tell a 4/4 hold they'd skimped.
     *
     * B and D both arrived here independently with the numbers, and B checked
     * what it does NOT break: cheap rigs still lose (4× carabiner → hp 38), rigs
     * that miss the bed still lose (hp 36), and $80 still cannot buy immunity.
     * The corners are priced, not gated.
     */
    const win = hp >= 50;
    const dmg = garden.damage;
    const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped });

    return {
      hp,
      cornersLost: lost.length,
      cornersTotal: rig.corners.length || 4,
      bill,
      collateral,
      budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
      win,
      subtitle: lost.length
        ? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
        : 'Every corner held.',
      /** Decision 13's headline: how much of the bed's damage the cloth stopped. */
      hailBlocked: dmg.hail + dmg.rain > 0
        ? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
        : 'Nothing reached the bed.',
      /**
       * What you can unclip and take home: hardware still on an unbroken corner,
       * plus a spare you never had to use. week.js refunds it at half — a shackle
       * that rode out a gale isn't new any more. Broken gear is worth nothing,
       * which is the whole of `bill`.
       */
      intactHardwareValue:
        rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
        + (rigging.session.spares ?? 0) * SPARE_COST,
      pondPeak,
      pondDumped,
      verdict,
      /** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
      verdictMode: mode,
    };
  }

  // --- the week ------------------------------------------------------------
  // Five nights, one wallet. The forecast stops being a difficulty picker — the
  // ladder decides what's coming and the only question left is what you rig
  // against it with the money you have.
  const week = createWeek();
  let spentThisNight = 0;

  /**
   * Tonight's card. Reads the bank, not START_BUDGET.
   *
   * NOTE the `budget` we hand the shop: `rigging.session.spent` computes
   * `START_BUDGET - budget` against the module constant rather than the session's
   * own starting cash, so with a bank of $63 it reports $17 spent before you buy
   * anything. Lane B's file, flagged in THREADS — main.js therefore tracks
   * `spentThisNight` itself off the bank rather than trusting `.spent`.
   */
  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
    // than by forking their file. I've asked for `session.setBudget(n)` in
    // THREADS; when it lands these two lines become one. (Same route
    // `session.reset()` took: I faked it, asked, they landed it, I deleted the
    // fake.)
    rigging.session._startBudget = week.bank;
    rigging.session.reset();
    spentThisNight = 0;
    hud.showForecast(
      { 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'); },
    );
  }

  // --- the night's evidence ------------------------------------------------
  // What the sail carried and what the player took on the head, so the verdict
  // can point at things that actually happened rather than infer from the score.
  let pondPeak = 0;
  let pondDumped = 0;

  // Subscribed once: rigSail() calls attach() on the same rig object, so the
  // Emitter survives a re-rig. Lane B tags a dump with `reason` when the water
  // left because something failed (corner break, belly tear) and leaves it off
  // when the player poked it out with the broom — which is the difference
  // between "the sail lost its water" and "you wore it".
  rig.events.on('pondDump', (e) => {
    if (!e.reason) pondDumped += e.kg;
  });

  // --- phases -------------------------------------------------------------
  game.on('phaseChange', ({ to }) => {
    // Prep and forecast happen on the calm day; the storm you picked only
    // arrives when you say go.
    wind.use(to === 'storm' ? winds[stormKey] : calmWind);
    makeSky();
    events.length = 0;
    rigging.setActive(to === 'prep');

    if (to === 'storm') { pondPeak = 0; pondDumped = 0; }

    if (to === 'forecast') {
      hud.setDawn(false);
      // 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
    // (a debug jump, a future timer, "play again" landing somewhere new) left a
    // card floating over a live game, swallowing input.
    if (to === 'prep' || to === 'storm') hud.hideCard();

    if (to === 'prep') {
      hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four');
    }
    if (to === 'storm') {
      hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
    }
    if (to === 'aftermath') {
      // The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
      // is right and it's the whole reason this isn't one call: the storm ends,
      // the light returns, and only then are you told how you did. Sell the
      // survival first.
      hud.setDawn(true);
      const run = scoreRun();
      const settlement = week.settle(run, defs[week.stormKey], spentThisNight);
      hud.showAftermath({ ...run, week: settlement }, () => {
        if (settlement.outcome === 'continue') { week.advance(); game.setPhase('forecast'); }
        else hud.showEndCard(settlement, () => { week.reset(); game.setPhase('forecast'); });
      });
    }

    if (banner && to !== 'forecast' && to !== 'aftermath') {
      banner.textContent = to.toUpperCase();
      banner.style.opacity = '1';
      setTimeout(() => { banner.style.opacity = '0'; }, 1400);
    }
  });

  addEventListener('keydown', (e) => {
    if (e.key !== 'Enter' || hud.cardOpen) return;
    // Leaving prep means committing the rig — Lane B's session refuses if it
    // isn't four corners, and says so in the ticker.
    if (game.phase === 'prep') {
      if (!rigging.commit()) return;
      // Off the bank, not off START_BUDGET — see the note on showTonight().
      spentThisNight = week.bank - rigging.summary.budget;
      player.sim.carrying = null;
      if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`);
    }
    game.advance();
  });

  // Straight into the forecast: the card is the game's front door, and it now
  // opens on night one of five rather than a difficulty menu.
  showTonight();

  // --- resize -------------------------------------------------------------
  function resize() {
    const w = canvas.clientWidth || innerWidth;
    const h = canvas.clientHeight || innerHeight;
    renderer.setSize(w, h, false);
    cameraRig.resize(w, h);
  }
  addEventListener('resize', resize);
  resize();

  // --- loop ---------------------------------------------------------------
  const clock = new THREE.Clock();
  const dev = document.getElementById('dev');
  let frames = 0, fpsT = 0, fps = 0;

  function step(dt) {
    game.tick(dt);
    simT += dt;
    windT = windTime();
    world.update(dt, windT);
    player.update(dt, windT);
    rig.step(dt, wind, windT, debris);
    debris.step(dt, windT, { player: player.sim, sail: rig });
    sky?.step(dt, windT, { sail: rig });
    rigging.update(dt, windT);

    // Decision 7: what hurts the garden is weather the sail didn't stop. Only
    // during the storm — the calm day's drizzle is scenery.
    //
    // Through Lane C's combined helper rather than my own rain × (1 − shadow):
    // it's the same arithmetic, it's their term to own, and SPRINT5 decision 13
    // extends exactly this shape (+ gardenHailExposure) — so when hail lands
    // this is one added term here, not a rewrite.
    if (game.phase === 'storm') {
      // Decision 13 (SPRINT5): hail is the headline garden threat — it falls
      // steep, so the sail blocks it and the score finally rewards rigging
      // (C proved 4.4× separation). Rain stays as the small honest drain that
      // walks under a sail in a gale. Weights chosen so storm_02 unprotected
      // loses ~50 HP to its hail bursts (11.4 hail-seconds × 5.0 × 0.9/s) and
      // ~10 to rain, while a bed-covering rig cuts the hail term ~4.4×.
      const rainExp = sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0;
      const hailExp = sky?.gardenHailExposure ? sky.gardenHailExposure(world.gardenBed, windT) : 0;
      // Passed separately, not pre-summed: the split is what makes the verdict
      // able to tell "your hardware went" from "your sail wasn't over the bed".
      garden.step(dt, hailExp, rainExp);

      // Pond evidence for the aftermath. Peak is what the sail carried at its
      // worst; dumped is what the player took on the head with the broom. Both
      // are things the verdict can honestly point at.
      if (typeof rig.pondMass === 'function') {
        pondPeak = Math.max(pondPeak, rig.pondMass());
      }
    }
  }

  function frame() {
    // Clamped so a background tab or a breakpoint doesn't make the sim try to
    // catch up over thousands of steps and lock the page.
    const raw = Math.min(0.25, clock.getDelta());
    acc += raw;
    let guard = 0;
    while (acc >= FIXED_DT && guard++ < 60) {
      step(FIXED_DT);
      acc -= FIXED_DT;
    }

    cameraRig.update(raw, player.pos);
    sailView?.update();
    renderer.render(scene, cameraRig.object);

    hud.update(raw, windT);

    frames++; fpsT += raw;
    if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
    if (dev) dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s · t ${simT.toFixed(0)}s · debris ${debris.pieces.length}`;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  // 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, 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; },
    set stormKey(k) { stormKey = k; },
    scoreRun,
    get sky() { return sky; },
    get simT() { return simT; },
    windTime,
    calmWind,

    /**
     * Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to
     * a standstill in a hidden tab, so these are the only honest way to
     * fast-forward a storm or capture one from a headless browser — which is
     * exactly what this sprint's "90 s storm_02 run captured" acceptance needs.
     * Same code path the rAF loop uses; no test-only branch to drift.
     */
    step,
    render() {
      sailView?.update();
      renderer.render(scene, cameraRig.object);
    },
  };
  globalThis.SHADES = api;
  return api;
}
