← Natura·Docs/Modules/Source

biome.js

112 lines · in natura-src-1.0.0.zip as natura/biome.js · a module · its entry in the module reference · its test: biome-test.js

// biome.js -- Natura BIOME DYNAMICS (SYSTEMS.md #8, ALGORITHMS.md): the map of biomes CHANGES with the
// climate over time -- forests advance, deserts creep, tundra retreats up the mountains as the world warms.
// Each cell holds a CLIMATE (a long-run running average of the weather's temperature and rainfall, not the
// day's weather), and the climate-appropriate biome for that cell is found by a Whittaker-style classifier
// (the Terrain Engine's biome taxonomy -- sea/beach/ice/tundra/taiga/forest/grass/desert/marsh -- adapted
// to Natura's units). A cell does NOT flip the instant the weather wobbles: it accumulates SUCCESSION
// pressure toward the climate-favoured biome and only converts once that pressure has held long enough, so
// a biome front advances or retreats slowly and with hysteresis -- a real ecological succession, not noise.
//
// Reads weather (temp, rain) + an elevation sampler; an optional warmingPerYear forces a climate trend so
// the biomes visibly shift. Deterministic + node-testable. UMD (node test / window.Biome).
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Biome = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  // the Terrain Engine's biome set (kept identical so a biome index means the same thing across the kit)
  const BIOMES = ['sea', 'beach', 'ice', 'tundra', 'taiga', 'forest', 'grass', 'desert', 'marsh'];
  const COLD = new Set([2, 3, 4]);   // ice, tundra, taiga -- the biomes a warming world loses

  // classify a cell by elevation (world units), climate temperature (deg C), and climate moisture (0..1).
  // The Terrain Engine's biomeOf logic, re-expressed in Natura's units (its version took normalized t,p).
  function classify(elev, seaLevel, tC, moist) {
    if (elev < seaLevel) return 0;                       // sea
    if (elev < seaLevel + 0.02) return 1;                // beach
    if (tC < -6) return 2;                               // ice cap
    if (tC < 1) return 3;                                // tundra
    if (tC > 45) return 7;                               // scorched barren (a red-giant heat, or a hot desert) -> nothing lives
    if (moist < 0.22) return 7;                          // desert (dry, whatever the warmth)
    if (moist > 0.80 && elev < seaLevel + 0.10) return 8; // marsh (wet lowland)
    if (tC < 8) return 4;                                // taiga (cool + moist)
    if (moist > 0.45) return 5;                          // forest
    return 6;                                            // grassland
  }

  // make a Biome system. config: { N, weather, elevation, seaLevel, climateTau(days), successionDays,
  //   precipRef, warmingPerYear }.
  function makeBiome(config) {
    config = config || {};
    const N = config.N || 64;
    const weatherName = config.weather || 'weather';
    const seaLevel = config.seaLevel != null ? config.seaLevel : 0;
    const climateTau = config.climateTau != null ? config.climateTau : 730;   // climate = ~2-year running mean
    const maxDtDays = config.maxDtDays != null ? config.maxDtDays : 3;         // per-step cap on sim-days; raise it hugely for DEEP TIME so biome equilibrates quasi-statically (over a Myr biomes fully settle)
    const successionDays = config.successionDays != null ? config.successionDays : 900;  // how long a new climate must hold to convert
    const precipRef = config.precipRef != null ? config.precipRef : 0.014;    // rain EMA -> moisture 0..1 scale (matched to weather rain magnitudes)
    const warmingPerYear = config.warmingPerYear != null ? config.warmingPerYear : 0;    // climate forcing (deg C / year)
    const stellarName = config.stellar || 'stellar';
    const insolSensitivity = config.insolSensitivity != null ? config.insolSensitivity : 40;  // deg C per unit change in stellar insolation
    const elevationFrom = config.elevationFrom || null;         // read a LIVE elevation field (e.g. 'tectonics') instead of the static init copy
    const elevFn = config.elevation || ((x, y) => 0.2);
    const gx = (i) => (i / (N - 1)) * 2 - 1;

    return {
      name: config.name || 'biome',
      domain: 'life',
      scale: 'regional',
      params: config,

      init() {
        const elev = new Float32Array(N * N);
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) elev[j * N + i] = elevFn(gx(i), gx(j));
        return { N, elev, biome: new Int8Array(N * N).fill(-1),
                 climT: new Float32Array(N * N), climP: new Float32Array(N * N),
                 progress: new Float32Array(N * N), toward: new Int8Array(N * N).fill(-1),
                 seeded: false, shifts: 0 };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, maxDtDays); if (dd <= 0) return st;
        const w = ctx.fields[weatherName];
        if (!w || !w.temp) return st;
        const temp = w.temp, rain = w.rain, { elev, biome, climT, climP, progress, toward } = st;
        const aC = 1 - Math.exp(-dd / climateTau);                 // climate EMA weight
        const stel = ctx.fields[stellarName];                      // a star's insolation drives the deep-time climate
        const warm = warmingPerYear * (ctx.clock.t / (365 * 86400)) + (stel ? insolSensitivity * (stel.insolation - 1) : 0);
        const ev = (elevationFrom && ctx.fields[elevationFrom] && ctx.fields[elevationFrom].elevation) || elev;  // live (evolving) elevation if wired, else the static init copy
        st.shifts = 0;
        // seed the climate + biomes from the first real weather field
        if (!st.seeded) {
          for (let k = 0; k < N * N; k++) { climT[k] = temp[k]; climP[k] = (rain ? rain[k] : 0); }
          for (let k = 0; k < N * N; k++) {
            const moist = clamp(climP[k] / precipRef, 0, 1);
            biome[k] = classify(ev[k], seaLevel, climT[k] + warm, moist);
          }
          st.seeded = true; return st;
        }
        for (let k = 0; k < N * N; k++) {
          climT[k] += (temp[k] - climT[k]) * aC;                   // update the running CLIMATE
          climP[k] += ((rain ? rain[k] : 0) - climP[k]) * aC;
          if (ev[k] < seaLevel) { biome[k] = 0; continue; }      // sea stays sea
          const moist = clamp(climP[k] / precipRef, 0, 1);
          const target = classify(ev[k], seaLevel, climT[k] + warm, moist);
          if (target === biome[k]) { progress[k] = Math.max(0, progress[k] - dd); toward[k] = -1; continue; }
          // SUCCESSION: pressure toward the climate-favoured biome; convert once it has held long enough
          if (toward[k] !== target) { toward[k] = target; progress[k] = 0; }
          progress[k] += dd;
          if (progress[k] >= successionDays) { biome[k] = target; progress[k] = 0; toward[k] = -1; st.shifts++; }
        }
        return st;
      },

      // OUT: biome (index per cell, matching BIOMES), climT/climP (the climate) for anything that wants it
      fields(st) { return { N: st.N, biome: st.biome, climateTemp: st.climT, climateMoist: st.climP, BIOMES }; },
      events(st) { return st.shifts > 0 ? [{ kind: 'biomeShift', cells: st.shifts }] : []; }
    };
  }

  return { makeBiome, classify, BIOMES, COLD };
});