← Natura·Docs/Modules/Source

lifecycle.js

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

// lifecycle.js -- Natura LIFE-CYCLE (SYSTEMS.md #6; the LIFE domain's last unbuilt system). A creature's
// life as a state machine, and a POPULATION as the turnover of many: birth -> growth -> maturity -> aging
// -> death -> decay -> nutrients -> (the ground that feeds the next). The ontology's biological continuum
// made mechanical -- "LIFE is matter passing THROUGH life; death is its tail." Each creature ages on the
// clock, eats (energy in) against its metabolism (energy out), grows through stages by age, breeds when
// adult and fed, and dies of starvation, cold, or old age; its carcass decays back into a nutrient field.
// The emergence is the TURNOVER: individuals die, yet the population persists across generations, holds an
// age STRUCTURE, and rides its food's carrying capacity -- and the matter that made the dead returns to the
// soil. Couples to VEGETATION (food) + WEATHER (cold stress); its OUT (creatures + nutrients) feeds a viz,
// the ecosystem, and the soil. In a minded world each creature carries a MIND that drives its behaviour
// (BUILD ON). Deterministic + node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Lifecycle = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  // stages -- EMPTY is a free pool slot; CARCASS is a dead body still decaying (occupies a slot till gone)
  const EMPTY = 0, EGG = 1, JUV = 2, ADULT = 3, SEN = 4, CARCASS = 5;

  function makeLifecycle(config) {
    config = config || {};
    const cap = config.maxCreatures || 500;
    const vegName = config.vegetation || 'vegetation';
    const weatherName = config.weather || 'weather';
    const foodFn = config.food || null;                 // (x,y,ctx)->0..1; else read vegetation, else a default patchy field
    const NN = config.nutrientN || 32;
    const P = Object.assign({
      n0: 120,
      hatchDays: 6, matureDays: 30, senesceDays: 130, maxDays: 185,   // stage thresholds (sim-days)
      metab: 0.05,                                       // base energy/day burned
      metabJuv: 0.9, metabAdult: 1.0, metabSen: 1.25,    // per-stage metabolic multiplier (young cheap, old dear)
      grazeRate: 0.12,                                   // energy/day gained per unit food (juv+adult forage)
      starveRate: 0.5, coldRate: 0.5, healRate: 0.7,     // health loss when starving/cold, gain when fed & warm (per day)
      reproThresh: 0.7, reproCost: 0.45, reproDailyP: 0.07,   // an adult, fed above threshold, breeds with this daily chance
      eggEnergy: 0.5, startEnergy: 0.6,
      decayDays: 18, nutrientPerBody: 1.0, nutrientDecay: 0.02,   // carcass -> nutrients over decayDays; soil slowly reuptakes
      coldT: 3.0                                         // temperature (C) below which a creature suffers cold stress
    }, config.params || {});

    const cellOf = (v) => clamp(Math.floor((v + 1) / 2 * NN), 0, NN - 1);

    function defaultFood(x, y) { return clamp(0.45 + 0.4 * Math.sin(x * 2.7 + 0.5) * Math.cos(y * 2.3 - 0.3), 0, 1); }

    return {
      name: config.name || 'lifecycle',
      domain: 'life',
      scale: 'local',
      params: config,

      init(rng) {
        const st = {
          cap, NN,
          stage: new Uint8Array(cap), age: new Float32Array(cap), energy: new Float32Array(cap),
          health: new Float32Array(cap), x: new Float32Array(cap), y: new Float32Array(cap), carcassT: new Float32Array(cap),
          nutrients: new Float32Array(NN * NN),
          births: 0, deaths: 0, dStarve: 0, dCold: 0, dOld: 0,
          nAlive: 0, nEgg: 0, nJuv: 0, nAdult: 0, nSen: 0, nCarcass: 0, meanAge: 0,
          evs: []
        };
        for (let i = 0; i < P.n0 && i < cap; i++) {
          st.x[i] = rng() * 2 - 1; st.y[i] = rng() * 2 - 1;
          const a = rng(); // seed a spread of ages/stages so there is structure from the start
          st.age[i] = a * P.senesceDays;
          st.stage[i] = st.age[i] < P.hatchDays ? EGG : st.age[i] < P.matureDays ? JUV : ADULT;
          st.energy[i] = P.startEnergy; st.health[i] = 0.9;
        }
        return st;
      },

      step(st, ctx, dt) {
        const dd = dt / 86400; if (dd <= 0) return st; const s = st; s.evs.length = 0;
        const rng = ctx.rng || Math.random;
        const veg = ctx.fields ? ctx.fields[vegName] : null;
        const wx = ctx.fields ? ctx.fields[weatherName] : null;
        const foodAt = foodFn ? (x, y) => clamp(foodFn(x, y, ctx), 0, 1)
          : veg && veg.biomass && veg.N ? (x, y) => { const n = veg.N, ci = clamp(Math.floor((x + 1) / 2 * n), 0, n - 1), cj = clamp(Math.floor((y + 1) / 2 * n), 0, n - 1); return clamp(veg.biomass[cj * n + ci], 0, 1); }
          : defaultFood;
        const tempAt = wx && wx.temp && wx.N ? (x, y) => { const n = wx.N, ci = clamp(Math.floor((x + 1) / 2 * n), 0, n - 1), cj = clamp(Math.floor((y + 1) / 2 * n), 0, n - 1); return wx.temp[cj * n + ci]; } : () => 15;
        // breeding season -- if a clock exposes a year phase, breed in the warm half; else always
        const yp = ctx.clock && ctx.clock.yearPhase ? ctx.clock.yearPhase() : 0.25;
        const breedSeason = 0.5 + 0.5 * Math.sin(yp * Math.PI * 2);   // 1 at mid-year, 0 opposite

        let nEgg = 0, nJuv = 0, nAdult = 0, nSen = 0, nCar = 0, ageSum = 0, alive = 0;
        const births = [];
        for (let i = 0; i < cap; i++) {
          const stg = s.stage[i]; if (stg === EMPTY) continue;
          if (stg === CARCASS) {
            s.carcassT[i] += dd;
            s.nutrients[cellOf(s.y[i]) * NN + cellOf(s.x[i])] += P.nutrientPerBody * dd / P.decayDays;   // decay releases matter to the soil
            if (s.carcassT[i] >= P.decayDays) { s.stage[i] = EMPTY; s.evs.push({ kind: 'decayed', at: [s.x[i], s.y[i]] }); }
            else nCar++;
            continue;
          }
          // --- alive: age, feed, health ---
          s.age[i] += dd;
          const met = P.metab * (stg === JUV ? P.metabJuv : stg === SEN ? P.metabSen : stg === EGG ? 0 : P.metabAdult);
          const gain = stg === EGG ? 0 : P.grazeRate * foodAt(s.x[i], s.y[i]);
          s.energy[i] = clamp(s.energy[i] + (gain - met) * dd, 0, 1.2);
          const cold = tempAt(s.x[i], s.y[i]) < P.coldT;
          if (s.energy[i] < 0.05) s.health[i] -= P.starveRate * dd;
          else if (cold) s.health[i] -= P.coldRate * dd * 0.6;
          else s.health[i] = clamp(s.health[i] + P.healRate * (1 - s.health[i]) * dd, 0, 1);
          s.health[i] = clamp(s.health[i], 0, 1);
          // --- stage transitions by age ---
          if (stg === EGG && s.age[i] >= P.hatchDays) { s.stage[i] = JUV; s.evs.push({ kind: 'hatch', mind: i }); }
          else if (stg === JUV && s.age[i] >= P.matureDays) s.stage[i] = ADULT;
          else if (stg === ADULT && s.age[i] >= P.senesceDays) s.stage[i] = SEN;
          // --- death: starvation/cold (health gone) or old age ---
          let died = null;
          if (s.health[i] <= 0) died = (s.energy[i] < 0.05 ? 'starve' : 'cold');
          else if (s.age[i] >= P.maxDays) died = 'old';
          if (died) {
            s.stage[i] = CARCASS; s.carcassT[i] = 0; s.deaths++;
            if (died === 'starve') s.dStarve++; else if (died === 'cold') s.dCold++; else s.dOld++;
            s.evs.push({ kind: 'death', mind: i, cause: died, age: s.age[i], at: [s.x[i], s.y[i]] });
            continue;
          }
          // --- reproduce: an adult, fed above threshold, in season ---
          if (s.stage[i] === ADULT && s.energy[i] > P.reproThresh && rng() < P.reproDailyP * breedSeason * dd) {
            s.energy[i] -= P.reproCost;
            births.push(i);
          }
          const ns = s.stage[i];
          if (ns === EGG) nEgg++; else if (ns === JUV) nJuv++; else if (ns === ADULT) nAdult++; else if (ns === SEN) nSen++;
          ageSum += s.age[i]; alive++;
        }
        // place the new eggs in free slots near their parent (a birth can fail if the world is full)
        for (const pi of births) {
          let slot = -1; for (let j = 0; j < cap; j++) if (s.stage[j] === EMPTY) { slot = j; break; }
          if (slot < 0) break;
          s.stage[slot] = EGG; s.age[slot] = 0; s.energy[slot] = P.eggEnergy; s.health[slot] = 1;
          s.x[slot] = clamp(s.x[pi] + (rng() - 0.5) * 0.12, -1, 1); s.y[slot] = clamp(s.y[pi] + (rng() - 0.5) * 0.12, -1, 1);
          s.births++; nEgg++; alive++;
          s.evs.push({ kind: 'birth', mind: slot, parent: pi, at: [s.x[slot], s.y[slot]] });
        }
        // soil slowly reabsorbs nutrients (they feed the next growth; keeps the field from saturating)
        for (let n = 0; n < NN * NN; n++) s.nutrients[n] *= (1 - P.nutrientDecay * dd);

        s.nEgg = nEgg; s.nJuv = nJuv; s.nAdult = nAdult; s.nSen = nSen; s.nCarcass = nCar;
        s.nAlive = alive; s.meanAge = alive ? ageSum / alive : 0;
        return s;
      },

      fields(st) {
        return {
          cap: st.cap, N: st.NN, nutrients: st.nutrients,
          stage: st.stage, age: st.age, energy: st.energy, health: st.health, x: st.x, y: st.y, carcassT: st.carcassT,
          nAlive: st.nAlive, nEgg: st.nEgg, nJuv: st.nJuv, nAdult: st.nAdult, nSen: st.nSen, nCarcass: st.nCarcass,
          meanAge: st.meanAge, births: st.births, deaths: st.deaths,
          dStarve: st.dStarve, dCold: st.dCold, dOld: st.dOld,
          STAGE: { EMPTY, EGG, JUV, ADULT, SEN, CARCASS }
        };
      },

      events(st) { return st.evs; }
    };
  }

  return { makeLifecycle, STAGE: { EMPTY, EGG, JUV, ADULT, SEN, CARCASS } };
});