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 };
});