← Natura·Docs/Modules/Source

settlements.js

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

// settlements.js -- Natura/EXISTA SETTLEMENTS: the human LONG GAME played out on a real watershed. A handful of
// villages sit on the valley the watershed.js aquifer feeds; each one DRAWS water from the SPATIAL water table
// (pumping it down, locally), FARMS the soil beside it (depleting a fertility it must fallow to restore), grows
// or shrinks by the water and food it can win, and passes a CONTAGION to its neighbours along the valley. The
// point is the tension the title names: a village can draw hard and crop hard for a big harvest NOW, or pace
// itself within what the land renews and last. Because the aquifer is a SHARED spatial field (watershed diffuses
// groundwater sideways), one village's greed is felt DOWNSTREAM -- an upstream over-pumper lowers the table under
// the villages below it, the tragedy of the commons made spatial. Run greedy and the valley booms then CRASHES
// (the aquifer bottoms out, wells go dry, the crowded and hungry catch the plague); run paced and it endures.
//
// This is the SPATIAL, watershed-grounded cousin of commonwealth.js (which couples the same forces as a
// non-spatial time series). It COUPLES to watershed.js: writes a `draw` field watershed subtracts from the table,
// reads back `depthToWater` to know how deep its wells must reach. soil.js is the deep pedogenesis model; the
// fallow/fertility here is a compact stand-in so the whole valley stays one tractable world. Deterministic
// (init rng + ctx.rng); dt seconds. UMD (node require / window.Settlements).
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Settlements = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  const lerp = (a, b, t) => a + (b - a) * t;
  function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

  // config: { N, watershed, villages:[{i,j,pop}], stewardship(0 greedy..1 paced),
  //   greedyDraw/pacedDraw (per-capita table draw), greedyCrop/pacedCrop (cropping intensity),
  //   drawRadius, dryDepth, Kbase, growth, cropDeplete, fallowRegen, beta0, incub, infDur, ifr,
  //   linkDist, introduceDay, introduceVillage, name }
  function makeSettlements(config) {
    config = config || {};
    const N = config.N || 48;
    const wsName = config.watershed || 'watershed';
    const steward = clamp(config.stewardship != null ? config.stewardship : 0.5, 0, 1);   // WATER stewardship (0 greedy draw .. 1 paced)
    const cropStew = clamp(config.cropStewardship != null ? config.cropStewardship : steward, 0, 1); // LAND stewardship (fallow); defaults to the water one
    const greedyDraw = config.greedyDraw != null ? config.greedyDraw : 3e-4;   // per-capita table draw when GREEDY (over the recharge)
    const pacedDraw = config.pacedDraw != null ? config.pacedDraw : 1.5e-5;    // per-capita table draw when PACED (within recharge)
    const greedyCrop = config.greedyCrop != null ? config.greedyCrop : 0.95;   // cropping intensity when greedy (no fallow)
    const pacedCrop = config.pacedCrop != null ? config.pacedCrop : 0.45;      // cropping intensity when paced (rotates/fallows)
    const drawRadius = config.drawRadius != null ? config.drawRadius : 1;      // wells pull a concentrated cone from a small area
    const wetDepth = config.wetDepth != null ? config.wetDepth : -0.02;        // table at/above this (near/above surface) -> water is plentiful
    const dryDepth = config.dryDepth != null ? config.dryDepth : 0.06;         // table drawn down this deep -> the well runs dry
    const declineRate = config.declineRate != null ? config.declineRate : 0.12;// when a village outgrows its means, it dies-back DOWN at this rate/yr (those who cannot leave)
    // FLOODS: the river that feeds the wells also, in the wet season, overtops the low villages beside it --
    // drowning some but laying down SILT that heals the soil. A village's flood exposure falls off with its
    // distance from a river cell, so the uplands are safe and the bottomlands pay (and are repaid) by the river.
    const floodDist = config.floodDist != null ? config.floodDist : 2.6;       // exposure reaches this many cells from a river cell
    const floodProb = config.floodProb != null ? config.floodProb : 0.5;       // wet-season flood likelihood for a fully-exposed village (per flood-year)
    const floodMortality = config.floodMortality != null ? config.floodMortality : 0.16; // fraction of an exposed village a flood takes
    const siltRestore = config.siltRestore != null ? config.siltRestore : 0.45;// fertility a flood's silt lays back onto the land (0..1)
    const Kbase = config.Kbase != null ? config.Kbase : 400;                   // a village's carrying capacity at full water+food
    const growth = config.growth != null ? config.growth : 0.04;              // intrinsic population growth (per YEAR)
    const cropDeplete = config.cropDeplete != null ? config.cropDeplete : 0.3; // fertility lost per year at full cropping
    const fallowRegen = config.fallowRegen != null ? config.fallowRegen : 0.35;// fertility restored per year of full fallow
    const beta0 = config.beta0 != null ? config.beta0 : 0.08;                 // per-day within-village transmission when WELL-fed (low; malnutrition multiplies it)
    const incub = config.incub != null ? config.incub : 4;                    // E->I days
    const infDur = config.infDur != null ? config.infDur : 8;                 // I->R/D days
    const ifr = config.ifr != null ? config.ifr : 0.02;                       // infection fatality
    const linkDist = config.linkDist != null ? config.linkDist : 14;          // villages within this cell-distance trade contacts (disease spreads)
    const crossBeta = config.crossBeta != null ? config.crossBeta : 0.05;     // between-village contagion strength along a link
    const introduceDay = config.introduceDay != null ? config.introduceDay : -1;   // day to seed an infection (-1 = never)
    const introduceVillage = config.introduceVillage != null ? config.introduceVillage : 0;

    const idx = (i, j) => j * N + i;

    return {
      name: config.name || 'settlements',
      domain: 'society',
      scale: 'regional',
      params: config,

      init(rng0) {
        const rng = rng0 || mulberry32(config.seed || 1);
        const src = config.villages || [{ i: (N * 0.5) | 0, j: (N * 0.3) | 0, pop: 60 }];
        const villages = src.map((v, id) => ({
          id, i: v.i, j: v.j, pop: v.pop != null ? v.pop : 60,
          fert: v.fert != null ? v.fert : 1, waterOK: 1, stress: 0,
          // per-village policy: a village can steward (or squander) its WATER and its LAND independently; each
          // defaults to the system-wide setting, so a valley can mix wise and greedy neighbours on one aquifer.
          steward: v.steward != null ? clamp(v.steward, 0, 1) : steward,
          cropStew: v.cropStew != null ? clamp(v.cropStew, 0, 1) : cropStew,
          _drawTotal: 0, S: 1, E: 0, I: 0, R: 0, dead: 0           // SIR fractions of the village
        }));
        // disease links: undirected edges between villages within linkDist (weighted by inverse distance)
        const links = [];
        for (let a = 0; a < villages.length; a++) for (let b = a + 1; b < villages.length; b++) {
          const dx = villages[a].i - villages[b].i, dy = villages[a].j - villages[b].j, d = Math.hypot(dx, dy);
          if (d <= linkDist) links.push({ a, b, w: 1 / (1 + d) });
        }
        return { N, villages, links, draw: new Float32Array(N * N), day: 0, peakInfected: 0, _rng: rng };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, 60);                        // days this step
        if (dd <= 0) return st;
        const yrs = dt / (365 * 86400);
        const ws = ctx && ctx.fields && ctx.fields[wsName] ? ctx.fields[wsName] : null;
        const depth = ws && ws.depthToWater ? ws.depthToWater : null;   // filled - waterTable (grows as the table falls)
        const rng = (ctx && ctx.rng) || st._rng;
        st.day += dd;
        // each village's FLOOD EXPOSURE = how close it sits to a river cell (computed once, when the drainage is known)
        if (!st._floodExp && ws && ws.riverMask) {
          st._floodExp = st.villages.map((v) => {
            let best = Infinity;
            for (let jj = 0; jj < N; jj++) for (let ii = 0; ii < N; ii++) if (ws.riverMask[jj * N + ii]) { const d = Math.hypot(ii - v.i, jj - v.j); if (d < best) best = d; }
            return clamp(1 - best / floodDist, 0, 1);
          });
        }
        // the river's stage over the year: a wet-season crest (when floods come) and a dry-season low
        const seasonWet = ctx && ctx.clock && ctx.clock.yearPhase ? clamp(0.5 + 0.5 * Math.sin((ctx.clock.yearPhase() - 0.1) * 2 * Math.PI), 0, 1) : 0.5;

        // seed an epidemic if asked (once, when the day passes the trigger)
        if (introduceDay >= 0 && !st._seeded && st.day >= introduceDay) {
          const v = st.villages[clamp(introduceVillage, 0, st.villages.length - 1)];
          if (v) { v.I = Math.max(v.I, 0.02); v.S = Math.max(0, v.S - 0.02); st._seeded = true; }
        }

        // --- per-village dynamics: water, food, population ------------------------------------------------
        for (const v of st.villages) {
          const k = idx(v.i, v.j);
          const cropInt = lerp(greedyCrop, pacedCrop, v.cropStew);   // this village's LAND policy
          // wells: how deep is the table under this village? SIGNED depth (negative = at/above surface). A smooth
          // two-point curve: plentiful when the table is near/above the surface, dry once it is drawn down deep.
          const dw = depth ? depth[k] : wetDepth;
          v.waterOK = clamp((dryDepth - dw) / (dryDepth - wetDepth), 0, 1);   // 1 = plenty, 0 = the well is dry
          // fertility: cropping depletes it, the un-cropped share (fallow) restores it toward 1
          v.fert = clamp(v.fert + (fallowRegen * (1 - cropInt) - cropDeplete * cropInt) * yrs, 0, 1);
          const food = cropInt * v.fert * (0.35 + 0.65 * v.waterOK); // a crop needs water to grow
          // carrying capacity: gated by water, lifted by food
          const K = Kbase * v.waterOK * (0.3 + 0.7 * clamp(food / 0.5, 0, 1));
          v.stress = 1 - clamp(K / Math.max(1, v.pop), 0, 1);        // >0 when the village outgrows what the land gives
          // population: grow logistically toward K when there is room; when the village has OUTGROWN its means
          // (K < pop), it declines toward K at a BOUNDED rate (starvation/emigration) -- never the explosive
          // logistic term, which would annihilate a village the instant its wells or fields fail.
          if (K >= v.pop) v.pop += growth * v.pop * (1 - v.pop / Math.max(1, K)) * (365 * yrs);
          else v.pop += (K - v.pop) * clamp(declineRate * (365 * yrs), 0, 0.6);
          v.pop = Math.max(0, v.pop);
          // FLOOD: a river-exposed village, at the wet-season crest, is overtopped -- it loses people but the
          // silt heals its soil. The two-sided gift of the very river the wells lean on.
          const exp = st._floodExp ? st._floodExp[v.id] : 0;
          if (exp > 0 && v.pop > 1 && seasonWet > 0.72 && rng() < floodProb * exp * (seasonWet - 0.72) / 0.28 * clamp(dd / 20, 0, 1)) {
            v.pop *= (1 - floodMortality * exp);
            v.fert = clamp(v.fert + siltRestore * exp, 0, 1);
            v._floodedDay = st.day; st._floods = (st._floods || 0) + 1;
          }
          v._drawTotal = lerp(greedyDraw, pacedDraw, v.steward) * v.pop;   // this village's total pull on the table (per-village WATER policy)
        }

        // --- contagion across the valley (settlement-level SIR) ------------------------------------------
        // within a village, a standard S-E-I-R with malnutrition/crowding raising transmission; between
        // villages, infection leaks along the links -- so a plague travels the valley, worst where people are
        // stressed and hungry (exactly commonwealth.js's spark, here on a spatial network).
        const inflow = st.villages.map(() => 0);
        for (const L of st.links) { inflow[L.a] += crossBeta * L.w * st.villages[L.b].I; inflow[L.b] += crossBeta * L.w * st.villages[L.a].I; }
        for (let vi = 0; vi < st.villages.length; vi++) {
          const v = st.villages[vi];
          // MALNUTRITION drives transmission: a valley whose wells have failed and whose soil is mined out is
          // hungry and thirsty, and disease tears through it -- while a well-fed, well-watered valley shrugs off
          // the same pathogen (a low baseline R0 that malnutrition multiplies). This is why greed invites plague.
          const malnut = 1 - clamp(v.waterOK * (0.3 + 0.7 * v.fert), 0, 1);
          const beta = beta0 * (1 + 2.6 * malnut);
          const newE = clamp((beta * v.S * v.I + inflow[vi] * v.S) * dd, 0, v.S);
          const eToI = clamp(v.E * (dd / incub), 0, v.E);
          const iOut = clamp(v.I * (dd / infDur), 0, v.I);
          v.S -= newE; v.E += newE - eToI; v.I += eToI - iOut;
          const deaths = iOut * ifr; v.R += iOut - deaths; v.dead += deaths;
          v.pop = Math.max(0, v.pop * (1 - deaths));                 // the dead are lost from the living population
          if (v.I > st.peakInfected) st.peakInfected = v.I;
        }

        // --- write the DRAW field watershed will subtract from the table next tick -----------------------
        const draw = st.draw; draw.fill(0);
        for (const v of st.villages) {
          const total = v._drawTotal || 0;                          // the village's total pull, spread over its well field
          let wsum = 0; const cells = [];
          for (let dj = -drawRadius; dj <= drawRadius; dj++) for (let di = -drawRadius; di <= drawRadius; di++) {
            const ii = v.i + di, jj = v.j + dj; if (ii < 0 || jj < 0 || ii >= N || jj >= N) continue;
            const w = 1 / (1 + di * di + dj * dj); cells.push([idx(ii, jj), w]); wsum += w;
          }
          for (const [c, w] of cells) draw[c] += total * (w / wsum);
        }
        return st;
      },

      // OUT: draw (watershed reads this), the villages (position + pop + fert + waterOK + sickness + stress),
      // and valley aggregates -- totalPop, meanFert, peakInfected, totalDead, alive (villages not collapsed).
      fields(st) {
        let totalPop = 0, mf = 0, dead = 0, sick = 0, alive = 0;
        for (const v of st.villages) { totalPop += v.pop; mf += v.fert; dead += v.dead * v.pop; sick += v.I; if (v.pop > 1) alive++; }
        const nv = st.villages.length || 1;
        return {
          N: st.N, draw: st.draw, villages: st.villages,
          totalPop: +totalPop.toFixed(2), meanFert: +(mf / nv).toFixed(4), meanInfected: +(sick / nv).toFixed(4),
          peakInfected: +st.peakInfected.toFixed(4), totalDead: +dead.toFixed(3), alive, day: +st.day.toFixed(1),
          floods: st._floods || 0
        };
      },
      events(st) {
        const evs = [];
        for (const v of st.villages) {
          if (v.pop <= 1 && !v._collapsed) { v._collapsed = true; evs.push({ kind: 'village-collapse', id: v.id, i: v.i, j: v.j }); }
          if (v._floodedDay === st.day && v._floodedDay > 0) evs.push({ kind: 'flood', id: v.id, i: v.i, j: v.j });
        }
        return evs;
      }
    };
  }

  return { makeSettlements };
});