← Natura·Docs/Modules/Source

ecosystem.js

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

// ecosystem.js -- Natura ECOSYSTEM (SYSTEMS.md #7, ALGORITHMS.md): creatures living on the world.
// Agent-based predator-prey: HERBIVORES graze the vegetation (energy in, biomass out), move toward
// greener ground and flee predators, breed when fed and die when starved or old; PREDATORS hunt the
// herbivores, breed when fed, starve when the prey thin out. Out of these local rules the classic
// LOTKA-VOLTERRA oscillation emerges -- prey rise, predators rise chasing them, prey crash, predators
// starve, prey recover -- and the food web is drawn on the real vegetated, rained-on world beneath.
//
// A Natura system: agents in State, exposes a GRAZING field (vegetation reads it to lose biomass) +
// populations + the agents (for viz). Deterministic + node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) { module.exports = factory(require('./verbs/flee').flee); }
  else { var _f = root.NaturaVerbs && root.NaturaVerbs.flee; root.Ecosystem = factory(_f ? _f.flee : (root.Verbs && root.Verbs.flee)); }
})(typeof self !== 'undefined' ? self : this, function (flee) {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  const HERB = 0, PRED = 1;

  function makeEcosystem(config) {
    config = config || {};
    const N = config.N || 48;                       // grazing-field resolution (match vegetation)
    const vegName = config.vegetation || 'vegetation';
    const syncName = config.sync || null;   // OPTIONAL: a sync system whose rhythm the calling species flash by (a CHORUS)
    const P = Object.assign({
      herb0: 130, pred0: 16, maxAgents: 1500,
      herbSpeed: 0.09, predSpeed: 0.11, vision: 0.13, catchR: 0.024,   // speeds are domain-units per DAY (dt-scaled, so viewing speed never changes the biology)
      herbMetab: 0.018, predMetab: 0.030,           // energy/day burned (predators burn faster)
      grazeRate: 0.55, energyPerVeg: 1.25,          // herbivore feeding (energyPerVeg low -> modest carrying capacity)
      preyEnergy: 0.52,                             // energy a predator gains per kill
      refugeK: 0.7, refugeMax: 0.72,                // PREY REFUGE: herbivores in dense vegetation are
                                                    // harder to catch (cover). A Type-III-like low-density
                                                    // refuge that DAMPS the predator-prey oscillation so it
                                                    // stops swinging through local extinction -- prey shelter
                                                    // where the food is. refuge = min(refugeMax, refugeK*biomass).
      herbRepro: 1.0, predRepro: 1.4,               // energy needed to breed
      herbReproP: 0.045, predReproP: 0.07,          // daily breed chance once fed (per day, x dd)
      herbMaxAge: 240, predMaxAge: 300,             // days
      startE: 0.6,
      digestDays: 2.4,                              // after a kill a predator is SATED and rests/digests this long, NOT hunting (a real digestion cycle -> curbs predation dominance without parameter-tuning)
      immHerb: 0.5, immPred: 0.09,                  // IMMIGRATION/day (wanderers -> rescue from extinction, an open population)
      // YOUNG VULNERABILITY (ontology: LIFE #decline being-preyed-upon + SOCIETY #cooperation tending-young). A
      // self-regulating verb: predators crop the YOUNG -- the recruitment surplus -- not the breeders, so a herbivore
      // boom is curbed AT THE SOURCE without tuning a knob (the operator's "add a mechanic, don't tune" principle).
      // Young are slower + preferred + caught more easily + a smaller meal; they cohere to an adult; an adult holds
      // ground near young (tends) rather than bolting and abandoning them.
      youngVuln: true, youngAge: 16, youngSlow: 0.78, youngPrefer: 0.7, youngCatch: 1.3, youngRefuge: 0.8,
      youngCohesion: 1.2, tendCalm: 0.7,
      // DISEASE / EPIDEMIC (ontology: LIFE #decline infecting/diseasing + LIFE<->LIFE disease-spread + LIFE #death dying;
      // recovery -> immunity -> waning). A DENSITY-DEPENDENT self-regulating check: transmission needs CONTACT, so a
      // dense (boomed) herd catches fire with an epidemic that crashes it, then burns out as density falls and the
      // survivors are immune -- recurrent waves that cap booms WITHOUT tuning a knob (the "add a mechanic" principle).
      // SEIR+D: susceptible -> (contact) incubating -> infectious (sick: energy-taxed + SLOWED -> easy prey) -> recover
      // (immune, then waning back to susceptible) or die (leaving a carcass for the scavengers).
      disease: true, disR: 0.055, disBeta: 2.6, disInc: 3.5, disIll: 8, disMortality: 0.45, disImm: 55,
      disTax: 1.0, disSlow: 0.5, disSpark: 0.04, disPrey: 1.45, disFood: 0.35,
      // DAY/NIGHT ACTIVITY (ontology: LIFE #rest sleeping + MIND #state waking/sleeping; a crepuscular/nocturnal hunt).
      // Prey BED DOWN at night -- barely moving, not grazing, metabolism LOW (rest conserves energy), clustered and less
      // alert; predators hunt best in LOW LIGHT (dawn/dusk/night) when the prey are bedded and dull, and lull by day. So
      // predation gains a daily RHYTHM (a night hunt, a day respite) rather than running flat -- a temporal verb, not a knob.
      // Only active when the driver supplies ctx.tod (the visual time-of-day); without it (tests) night=0 -> zero change.
      dayNight: true, nightRest: 0.74, nightGraze: 0.85, nightMetab: 0.42, nightVigil: 0.5, nightCohesion: 0.9,
      predLowLightVis: 0.4, predLowLightCatch: 0.55, predDayLull: 0.2
    }, config.params || config);

    const gx = (i) => (i / (N - 1)) * 2 - 1;
    const cellOf = (x) => clamp(Math.round((x + 1) / 2 * (N - 1)), 0, N - 1);
    // TORUS support: a boundless world (config.wrap). Movement wraps at the edges, and sensing uses
    // the shortest toroidal delta so creatures near opposite edges are neighbours -- no wall, no seam,
    // so herds migrate across a "nearly infinite" space rather than piling against a boundary.
    const wrap = !!config.wrap;
    // OPEN-ZONE mode (config.emigrate): the map is one zone of a larger world; a creature that wanders
    // off an edge has migrated to the adjacent zone (removed), while immHerb/immPred bring migrants IN
    // from those zones -- a two-way inter-zone flux, so the visible population is an open window on a
    // bigger metapopulation (not a sealed island). Distinct from wrap (a self-contained torus world).
    const emigrate = !!config.emigrate;
    const wrapC = (v) => wrap ? (((v + 1) % 2 + 2) % 2 - 1) : clamp(v, -0.99, 0.99);   // -> [-1,1)
    const td = (a, b) => { let d = a - b; if (wrap) { if (d > 1) d -= 2; else if (d < -1) d += 2; } return d; }; // shortest wrap delta (domain width 2)
    const tdelta = (o, q) => [td(o.x, q.x), td(o.y, q.y)];   // this world's toroidal metric, handed to the shared flee verb

    return {
      name: config.name || 'ecosystem',
      domain: 'life',
      scale: 'ecological',
      params: P,

      init(rng) {
        return { N, rng, agents: [], grazing: new Float32Array(N * N), land: null, carcasses: [],
                 pop: { herbivore: 0, predator: 0 }, killsYoung: 0, killsAdult: 0, seeded: false, chorusR: 0, chorusPhase: 0 };
      },

      step(st, ctx, dt) {
        // CHORUS coupling (opt-in): read a sync system's rhythm so a calling species can flash AS ONE.
        // A pure readout -- it never touches population dynamics, so an uncoupled world is byte-identical.
        if (syncName && ctx.fields[syncName]) { st.chorusR = ctx.fields[syncName].R || 0; st.chorusPhase = ctx.fields[syncName].meanPhase || 0; }
        const veg = ctx.fields[vegName];
        if (!veg || !veg.biomass) return st;         // creatures need a vegetated world
        const dd = clamp(dt / 86400, 0, 2); if (dd <= 0) return st;
        // DAY/NIGHT: read the driver's time-of-day (0 midnight .. 0.5 noon). Absent -> stays day, zero effect.
        let night = 0, lowLight = 0;
        const tod = (ctx && ctx.tod != null) ? ctx.tod : (st.__tod != null ? st.__tod : null);   // driver's time-of-day (ctx or a state field); absent -> inert
        const dnActive = !!(P.dayNight && tod != null);
        if (dnActive) { const c = Math.cos(tod * 6.2831853); night = Math.max(0, c); lowLight = 1 - Math.max(0, -c); }
        const rng = st.rng, bio = veg.biomass, land = veg.land, VN = veg.N || N;
        const idxOf = (x) => { let u = (x + 1) / 2; u = wrap ? ((u % 1) + 1) % 1 : clamp(u, 0, 1); return clamp(Math.round(u * (VN - 1)), 0, VN - 1); };
        const bAt = (x, y) => bio[idxOf(y) * VN + idxOf(x)];
        const onLand = (x, y) => { if (!land) return true; return land[idxOf(y) * VN + idxOf(x)] === 1; };

        // seed the initial creatures on land, once the world exists
        if (!st.seeded) {
          const spawn = (sp, n) => { let placed = 0, tries = 0; while (placed < n && tries < n * 40) { tries++; const x = rng() * 2 - 1, y = rng() * 2 - 1; if (onLand(x, y)) { st.agents.push({ sp, x, y, vx: 0, vy: 0, e: P.startE * (0.6 + rng() * 0.8), age: rng() * 40, alive: true }); placed++; } } };
          spawn(HERB, P.herb0); spawn(PRED, P.pred0); st.seeded = true;
        }

        const A = st.agents, graze = st.grazing; graze.fill(0);
        if (!st.carcasses) st.carcasses = [];               // carrion left by kills; scavengers contest it, then it rots
        const herbs = A.filter(a => a.alive && a.sp === HERB);
        const born = [];
        // DISEASE TRANSMISSION (a pre-pass on start-of-step positions -> order-independent): a rare spontaneous SPARK
        // seeds a susceptible, then each INFECTIOUS agent exposes nearby SUSCEPTIBLE same-kind by a density-dependent
        // force of infection -- a dense herd ignites, a sparse one cannot. Susceptible = no inc/ill/imm timer running.
        if (P.disease) {
          if (rng() < P.disSpark * dd) { for (let t = 0; t < 8; t++) { const v = A[(rng() * A.length) | 0]; if (v && v.alive && !v.inc && !v.ill && !v.imm) { v.inc = P.disInc; break; } } }
          const infec = A.filter(a => a.alive && a.ill > 0);
          if (infec.length) { const r2 = P.disR * P.disR;
            for (const a of A) { if (!a.alive || a.inc || a.ill || a.imm) continue;              // only the susceptible can be exposed
              let load = 0; for (const b of infec) { if (b.sp !== a.sp) continue; const dx = td(a.x, b.x), dy = td(a.y, b.y); if (dx * dx + dy * dy < r2) load++; }
              if (load && rng() < 1 - Math.exp(-P.disBeta * load * dd)) a.inc = P.disInc; } }   // 1 - e^(-beta*load*dt): the chance at least one contact infects this step
        }
        for (const a of A) {
          if (!a.alive) continue;
          a.age += dd; a.e -= (a.sp === HERB ? P.herbMetab * (1 - (1 - P.nightMetab) * night) : P.predMetab) * dd;   // a bedded prey burns less (LIFE #rest: sleeping)
          // DISEASE progression: incubate -> fall ill (energy-taxed) -> recover (immune) or DIE (leaving a carcass); immunity then wanes
          if (P.disease) {
            if (a.inc > 0) { a.inc -= dd; if (a.inc <= 0) a.ill = P.disIll; }
            else if (a.ill > 0) { a.ill -= dd; a.e -= P.disTax * dd;
              if (a.ill <= 0) { if (rng() < P.disMortality) { a.alive = false; st.disDeaths = (st.disDeaths || 0) + 1;
                  st.carcasses.push({ x: a.x, y: a.y, meat: P.preyEnergy * 1.1, age: 0, sp: a.__sp, dir: a.__dir || 1, infected: true }); }   // a disease death is carrion too (LIFE #death: being-scavenged) -- and an infection reservoir
                else a.imm = P.disImm; } }
            else if (a.imm > 0) a.imm -= dd;                                     // recovered immunity wanes back to susceptible -> the next wave is possible
            if (!a.alive) continue;
          }
          if (a.sp === HERB) {
            // graze the vegetation here
            const b = bAt(a.x, a.y), eat = Math.min(b, P.grazeRate * dd * b) * (1 - P.nightGraze * night);   // a bedded prey barely grazes
            a.e += eat * P.energyPerVeg;
            graze[cellOf(a.y) * N + cellOf(a.x)] += eat;      // vegetation loses this
            const young = P.youngVuln && a.age < P.youngAge;  // a fawn: slow, vulnerable, kept close to an adult
            // move: up the vegetation gradient + flee nearest predator
            let gxv = 0, gyv = 0; const d = 0.05;
            gxv = bAt(a.x + d, a.y) - bAt(a.x - d, a.y); gyv = bAt(a.x, a.y + d) - bAt(a.x, a.y - d);
            // SAFETY IN NUMBERS + YOUNG COHESION/TENDING: one pass over the herd counts neighbours, finds the nearest
            // ADULT (a young shelters by it) and whether a young is in THIS adult's care (it will hold ground for it).
            let neigh = 0, cxs = 0, cys = 0, herdR = (P.vision * 1.6) * (P.vision * 1.6);
            let nax = 0, nay = 0, nad = 1e9, youngNear = false, ynR = (P.vision * 0.95) * (P.vision * 0.95);
            for (const q of herbs) { if (q !== a) { const dx = td(a.x, q.x), dy = td(a.y, q.y), q2 = dx * dx + dy * dy;
              if (q2 < herdR) { neigh++; cxs += dx; cys += dy; }                   // (a-q) summed -> herd centroid direction, for bedding down together at night
              if (young && q.age >= P.youngAge && q2 < nad) { nad = q2; nax = dx; nay = dy; }   // (a-q); steer by -nax toward the adult
              if (P.youngVuln && !young && q.age < P.youngAge && q2 < ynR) youngNear = true; } }
            const vigil = (1.3 + 0.95 / (1 + neigh * 0.45)) * (1 - P.nightVigil * night);   // lone -> ~2.25x vision (very alert); in a herd -> ~1.5x; DULLED while bedded at night (the risk of sleep)
            // FLEE via the shared verb: raw (distance-weighted) sum of away-vectors under THIS world's toroidal metric,
            // predators perceived within the vigilance radius, bolted from within vision -- one ontology, no inline copy.
            const _fl = flee({ x: a.x, y: a.y }, A.filter(p => p.alive && p.sp === PRED), { near: P.vision, perceive: P.vision * vigil, raw: true, delta: tdelta });
            let fx = _fl.fx, fy = _fl.fy, wary = _fl.wary;
            if (youngNear) { fx *= P.tendCalm; fy *= P.tendCalm; }         // TENDING: an adult with young near holds ground rather than bolting off and abandoning them
            let mx = gxv * 3 + fx * 2 + (rng() - 0.5) * 0.5, my = gyv * 3 + fy * 2 + (rng() - 0.5) * 0.5;   // white-noise wander KEPT (this random-walk is the prey's dispersal -- do not remove it)
            if (young && nad < 1e9) { const cl = Math.hypot(nax, nay) || 1, cw = P.youngCohesion * (wary ? 0.55 : 1);   // stay close to an adult (run to it when scared, browse near it when calm)
              mx += (-nax / cl) * cw; my += (-nay / cl) * cw; }
            if (night > 0.05 && neigh > 0 && !wary) { mx += (-cxs / neigh) * P.nightCohesion * night; my += (-cys / neigh) * P.nightCohesion * night; }   // BED DOWN together at night (but a perceived predator still scatters the herd)
            const content = wary ? 0 : Math.min(1, b / 0.7);          // GRAZE-PAUSING: linger and crop the grass ONLY when no predator is perceived...
            const spd = P.herbSpeed * (0.4 + 0.6 * (1 - content)) * (young ? P.youngSlow : 1) * (1 - P.nightRest * night);   // ...at ~40% speed; WARY = full speed; YOUNG slower; and a bedded prey barely moves at night
            const ml = Math.hypot(mx, my) || 1; a.vx = mx / ml * spd; a.vy = my / ml * spd;
          } else if (a.sated > 0) {
            // DIGESTION: a predator that just ate is SATED -- it rests and digests, ignoring prey, and only
            // resumes hunting once the meal is processed. A hunt/eat/rest rhythm that curbs over-predation.
            a.sated -= dd;
            a.vx = (rng() - 0.5) * P.predSpeed * 0.3; a.vy = (rng() - 0.5) * P.predSpeed * 0.3;   // ambles slowly while full
          } else {
            // KILL-COMPETITION / SCAVENGING: a predator makes for the nearest CARCASS and feeds there,
            // contesting it with others -- so it is NOT out hunting live prey (a downed kill draws the pack in
            // and the live herd slips away). One carcass feeds several -> the same death sustains more predators
            // on FEWER live kills, which curbs predation. Nothing is wasted (LIFE #death: being-scavenged).
            let bc = null, bcd = (P.vision * 2.2) * (P.vision * 2.2);
            for (const c of st.carcasses) { if (c.meat > 0.02) { const dx = td(c.x, a.x), dy = td(c.y, a.y), d2 = dx * dx + dy * dy; if (d2 < bcd) { bcd = d2; bc = c; } } }
            if (bc && a.e < P.predRepro * 1.15) {              // a well-fed predator ignores carrion and keeps hunting; a hungrier one scavenges
              if (bcd < P.catchR * P.catchR * 3) { const bite = Math.min(bc.meat, P.preyEnergy * dd * 1.5); bc.meat -= bite; a.e += bite;   // feeding at the carcass (shared -> contested)
                if (bc.infected && P.disease && !a.inc && !a.ill && !a.imm && rng() < P.disFood * dd * 4) a.inc = P.disInc;   // an infected CARCASS is a reservoir -- scavengers contesting it risk it too
                a.vx = (rng() - 0.5) * P.predSpeed * 0.25; a.vy = (rng() - 0.5) * P.predSpeed * 0.25; }
              else { const dx = td(bc.x, a.x), dy = td(bc.y, a.y), l = Math.hypot(dx, dy) || 1; a.vx = dx / l * P.predSpeed; a.vy = dy / l * P.predSpeed; }   // move in to the carcass
            } else {
              // hunt the nearest LIVE herbivore -- PREFERRING the YOUNG (weighted nearer); eat if close
              const vf = dnActive ? (1 + P.predLowLightVis * lowLight - P.predDayLull * 0.5 * (1 - lowLight)) : 1;   // sees prey further in low light (dawn/dusk/night), duller by day
              let best = null, bd = (P.vision * vf) * (P.vision * vf);
              for (const h of herbs) { if (!h.alive) continue; const dx = td(h.x, a.x), dy = td(h.y, a.y);
                let dd2 = dx * dx + dy * dy; if (P.youngVuln && h.age < P.youngAge) dd2 *= P.youngPrefer;   // a fawn reads as the easier, nearer target
                if (P.disease && h.ill > 0) dd2 *= P.disPrey;                        // a visibly sick straggler reads as an easy target too (predators cull the ill)
                if (dd2 < bd) { bd = dd2; best = h; } }
              if (best) {
                const bY = P.youngVuln && best.age < P.youngAge;
                const bdx = td(best.x, a.x), bdy = td(best.y, a.y), bda = bdx * bdx + bdy * bdy;   // ACTUAL distance to the chosen prey (bd was preference-weighted)
                // catch if close, UNLESS the prey shelters in dense vegetation (refuge) -- a fawn hides less well AND is caught from further
                const refuge = Math.min(P.refugeMax, P.refugeK * bAt(best.x, best.y)) * (bY ? P.youngRefuge : 1);
                const hf = dnActive ? (1 + P.predLowLightCatch * lowLight - P.predDayLull * (1 - lowLight)) : 1;   // catches from further in low light; a day-lulled predator reaches less
                if (bda < (P.catchR * hf) * (P.catchR * hf) * (bY ? P.youngCatch : 1) && rng() > refuge) {
                  best.alive = false; a.e += P.preyEnergy; a.sated = P.digestDays;   // a fawn is EASIER prey, not worse food -- full meal (a young-as-small-meal starves the predators and BOOMS the herbivores)
                  if (bY) st.killsYoung++; else st.killsAdult++;                       // predators efficiently harvest the recruitment surplus -> herbivore growth curbed at the source
                  const sickPrey = P.disease && (best.ill > 0 || best.inc > 0);        // TROPHIC transmission: culling the sick carries a COST -- eat an infected prey and risk catching it (disease crosses the food web)
                  if (sickPrey && !a.inc && !a.ill && !a.imm && rng() < P.disFood) a.inc = P.disInc;
                  st.carcasses.push({ x: best.x, y: best.y, meat: P.preyEnergy * 1.3, age: 0, sp: best.__sp, dir: best.__dir || 1, infected: sickPrey }); }   // the carcass feeds the scavengers -- and carries the infection if the prey was sick
                else { const l = Math.hypot(bdx, bdy) || 1; a.vx = bdx / l * P.predSpeed; a.vy = bdy / l * P.predSpeed; }
              } else { a.vx = (rng() - 0.5) * P.predSpeed; a.vy = (rng() - 0.5) * P.predSpeed; }
            }
          }
          if (P.disease && a.ill > 0) { a.vx *= P.disSlow; a.vy *= P.disSlow; }   // a sick animal is weak and slow -> it lags the herd and is easily caught (disease -> predation coupling)
          // integrate, stay on land + in bounds
          let nx = a.x + a.vx * dd, ny = a.y + a.vy * dd;      // displacement scales with dt -> dt-invariant motion
          if (wrap) { nx = wrapC(nx); ny = wrapC(ny); }
          else if (emigrate && (nx < -1 || nx > 1 || ny < -1 || ny > 1)) { a.alive = false; }  // wandered to the next zone
          else { nx = clamp(nx, -0.99, 0.99); ny = clamp(ny, -0.99, 0.99); }
          if (a.alive && onLand(nx, ny)) { a.x = nx; a.y = ny; }
          // reproduce (fed) / die (starved or old)
          const rep = a.sp === HERB ? P.herbRepro : P.predRepro, repP = a.sp === HERB ? P.herbReproP : P.predReproP;
          if (a.e > rep && rng() < repP * dd && A.length + born.length < P.maxAgents) { a.e *= 0.5; born.push({ sp: a.sp, x: clamp(a.x + (rng() - 0.5) * 0.05, -0.99, 0.99), y: clamp(a.y + (rng() - 0.5) * 0.05, -0.99, 0.99), vx: 0, vy: 0, e: a.e, age: 0, alive: true }); }
          if (a.e <= 0 || a.age > (a.sp === HERB ? P.herbMaxAge : P.predMaxAge)) a.alive = false;
        }
        // CARRION rots: a carcass loses meat over time and is gone once eaten out or decomposed (nothing lingers forever)
        for (const c of st.carcasses) { c.age += dd; c.meat -= 0.03 * dd; }
        st.carcasses = st.carcasses.filter(c => c.meat > 0.02 && c.age < 25);
        // IMMIGRATION: wanderers arrive from beyond the map -- an open population, a rescue effect that
        // keeps a crash from being terminal (a real ecological mechanism, and the cure for stochastic extinction).
        if (rng() < P.immHerb * dd) { const x = rng() * 2 - 1, y = rng() * 2 - 1; if (onLand(x, y)) born.push({ sp: HERB, x, y, vx: 0, vy: 0, e: P.startE, age: 0, alive: true }); }
        if (rng() < P.immPred * dd) { const x = rng() * 2 - 1, y = rng() * 2 - 1; if (onLand(x, y)) born.push({ sp: PRED, x, y, vx: 0, vy: 0, e: P.startE, age: 0, alive: true }); }
        // cull the dead, add the born; cap population (drop weakest if over)
        st.agents = A.filter(a => a.alive).concat(born);
        if (st.agents.length > P.maxAgents) { st.agents.sort((x, y) => y.e - x.e); st.agents.length = P.maxAgents; }
        let h = 0, p = 0, sick = 0, imm = 0, sickP = 0; for (const a of st.agents) { const isH = a.sp === HERB; if (isH) h++; else p++; if (a.ill > 0) { sick++; if (!isH) sickP++; } else if (a.imm > 0) imm++; }
        st.pop = { herbivore: h, predator: p, sick, sickPred: sickP, immune: imm };
        return st;
      },

      fields(st) { return { N: st.N, grazing: st.grazing, populations: st.pop, agents: st.agents, chorusR: st.chorusR, chorusPhase: st.chorusPhase }; },
      events(st) { return []; }
    };
  }

  return { makeEcosystem };
});