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