← Natura·Docs/Modules/Source

settlements-test.js

117 lines · in natura-src-1.0.0.zip as natura/settlements-test.js · a test

// settlements-test.js -- THE LONG GAME, proven by RUNNING it (never by reasoning it "should" work). A valley of
// villages on the uplands above a watershed's river, each drawing from the SPATIAL aquifer and farming beside it.
// The emergences, each MEASURED and each ISOLATED (water and land stewardship are separate knobs, so one tragedy
// cannot masquerade as the other):
//   1. WATER TRAGEDY vs STEWARDSHIP -- greedy pumping draws the shared table DOWN, the wells fail, population
//      collapses; paced pumping within the recharge keeps the wells full and the valley populous.
//   2. SPATIAL EXTERNALITY -- an upstream over-pumper lowers the table under a DOWNSTREAM village (the aquifer's
//      lateral flow carries one village's greed to its neighbour -- the commons made spatial).
//   3. FALLOW -- over-cropping mines out the soil; fallowing keeps it fertile.
//   4. VALLEY EPIDEMIC -- a plague seeded into a stressed, hungry valley sweeps far worse than into a healthy one.
// Deterministic. node settlements-test.js.
const N = require('./natura.js');
const { makeWatershed } = require('./watershed.js');
const { makeSettlements } = require('./settlements.js');
const { World, UNITS } = N;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };
const GRID = 48;
// a valley: high north + along the flanks, draining SOUTH down a central channel; villages live on the UPLANDS.
const valley = (x, y) => 0.5 - 0.16 * y + 0.5 * x * x;
const gi = (xn) => Math.round((xn + 1) / 2 * (GRID - 1));   // normalised [-1,1] -> grid index
const UPLANDS = [[-0.45, -0.4], [0.45, -0.12], [-0.42, 0.15], [0.46, 0.35]].map(([xn, yn]) => ({ i: gi(xn), j: gi(yn), pop: 60 }));
const WS = { N: GRID, elevation: valley, temperature: 14, maxDtDays: 6, riverFrac: 0.02, recharge: 0.42 };

// build a valley world. waterStew / cropStew in [0,1] (0 greedy, 1 paced); disease opts optional.
function valleyWorld(waterStew, cropStew, opts) {
  return World({ seed: 5, systems: [
    makeWatershed(Object.assign({}, WS, { draw: 'settlements' })),
    makeSettlements(Object.assign({ N: GRID, watershed: 'watershed', villages: JSON.parse(JSON.stringify(UPLANDS)),
      stewardship: waterStew, cropStewardship: cropStew, seed: 5 }, opts || {}))
  ] });
}
const STEP = UNITS.DAY * 5;
function runYears(w, years) { const steps = Math.round(years * 365 / 5); for (let i = 0; i < steps; i++) w.step(STEP); return w; }
function villageMeans(w) {
  const sf = w.fields().settlements, ws = w.fields().watershed; let wOK = 0, depth = 0;
  for (const v of sf.villages) { wOK += v.waterOK; depth += ws.depthToWater[v.j * GRID + v.i]; }
  const n = sf.villages.length; return { pop: sf.totalPop, alive: sf.alive, fert: sf.meanFert, wOK: wOK / n, depth: depth / n, peak: sf.peakInfected };
}

console.log('settlements (THE LONG GAME) assertions:\n');

// 1. WATER TRAGEDY vs STEWARDSHIP (farming held sustainable, so WATER is the binding constraint) -------
{
  const greedy = villageMeans(runYears(valleyWorld(0, 1), 80));
  const paced = villageMeans(runYears(valleyWorld(1, 1), 80));
  ok(greedy.depth > paced.depth + 0.01, 'greedy pumping DRAWS DOWN the shared table far more (well depth greedy ' + greedy.depth.toFixed(3) + ' vs paced ' + paced.depth.toFixed(3) + ')');
  ok(greedy.wOK < paced.wOK - 0.2, 'greedy wells run toward DRY while paced wells stay full (waterOK greedy ' + greedy.wOK.toFixed(2) + ' vs paced ' + paced.wOK.toFixed(2) + ')');
  ok(paced.pop > greedy.pop * 1.8, 'the paced valley ENDURES far more people than the greedy one (paced ' + paced.pop.toFixed(0) + ' vs greedy ' + greedy.pop.toFixed(0) + ')');
  console.log('  80yr water: greedy pop ' + greedy.pop.toFixed(0) + ' (wOK ' + greedy.wOK.toFixed(2) + '), paced pop ' + paced.pop.toFixed(0) + ' (wOK ' + paced.wOK.toFixed(2) + ')');
}

// 2. SPATIAL EXTERNALITY: an upstream greedy pumper hurts a downstream paced village (per-village policy on
// ONE shared aquifer). The downstream village is PACED in both runs; only the presence of a greedy upstream
// neighbour differs. If the aquifer's lateral flow carries the drawdown, the downstream well suffers.
{
  const up = { i: gi(0.35), j: gi(-0.02), pop: 400, steward: 0, cropStew: 1 };   // greedy pumper, upstream
  const down = { i: gi(0.35), j: gi(0.10), pop: 60, steward: 1, cropStew: 1 };   // paced village, ~3 cells downstream
  const dk = down.j * GRID + down.i;
  // linkDist 0 -> no settlement link between them: the ONLY coupling is the shared aquifer (watershed's lateral
  // groundwater flow), so this isolates the physical externality from migration/contagion.
  const build = (villages) => World({ seed: 5, systems: [ makeWatershed(Object.assign({}, WS, { draw: 'settlements' })),
    makeSettlements({ N: GRID, villages: JSON.parse(JSON.stringify(villages)), linkDist: 0, seed: 5 }) ] });
  const ctrl = build([down]), test = build([up, down]);   // downstream alone vs downstream + greedy upstream
  runYears(ctrl, 55); runYears(test, 55);
  const depthCtrl = ctrl.fields().watershed.depthToWater[dk], depthTest = test.fields().watershed.depthToWater[dk];
  ok(depthTest > depthCtrl + 0.003, 'an upstream over-pumper lowers the DOWNSTREAM table (well depth ' + depthCtrl.toFixed(4) + ' alone -> ' + depthTest.toFixed(4) + ' with a greedy neighbour upstream)');
  console.log('  downstream well depth: ' + depthCtrl.toFixed(4) + ' (alone) vs ' + depthTest.toFixed(4) + ' (greedy neighbour upstream)');
}

// 3. FALLOW: over-cropping mines the soil; fallowing keeps it fertile (water held ample) ----------------
{
  const overcrop = runYears(valleyWorld(1, 0), 45).fields().settlements.meanFert;
  const fallowed = runYears(valleyWorld(1, 1), 45).fields().settlements.meanFert;
  ok(fallowed > overcrop + 0.3, 'fallowing keeps the soil fertile; over-cropping mines it out (fallowed ' + fallowed.toFixed(2) + ' vs over-cropped ' + overcrop.toFixed(2) + ')');
}

// 4. THE COMBINED LONG GAME: greed on BOTH water and land crashes the valley; stewardship sustains it ---
{
  const greedy = villageMeans(runYears(valleyWorld(0, 0), 90));
  const paced = villageMeans(runYears(valleyWorld(1, 1), 90));
  ok(paced.pop > greedy.pop * 2, 'playing the LONG GAME sustains a valley the greedy one loses (paced ' + paced.pop.toFixed(0) + ' vs greedy ' + greedy.pop.toFixed(0) + ')');
  ok(paced.fert > greedy.fert && paced.wOK > greedy.wOK, 'stewardship keeps BOTH the soil and the wells healthier (fert ' + paced.fert.toFixed(2) + ' vs ' + greedy.fert.toFixed(2) + ', wOK ' + paced.wOK.toFixed(2) + ' vs ' + greedy.wOK.toFixed(2) + ')');
}

// 5. VALLEY EPIDEMIC: a plague sweeps a stressed valley worse than a healthy one ------------------------
{
  const healthy = villageMeans(runYears(valleyWorld(1, 1, { introduceDay: 25 * 365, introduceVillage: 0 }), 45));
  const stressed = villageMeans(runYears(valleyWorld(0, 0, { introduceDay: 25 * 365, introduceVillage: 0 }), 45));
  ok(stressed.peak > 0.05, 'an epidemic ignites in the stressed valley (peak infected fraction ' + stressed.peak.toFixed(3) + ')');
  ok(stressed.peak > healthy.peak + 0.02, 'the plague sweeps the crowded, hungry valley WORSE than the healthy one (stressed ' + stressed.peak.toFixed(3) + ' vs healthy ' + healthy.peak.toFixed(3) + ')');
  console.log('  epidemic peak-infected: stressed ' + stressed.peak.toFixed(3) + ' vs healthy ' + healthy.peak.toFixed(3));
}

// 6. FLOODS: the river gives as it takes -- it drowns some of a bottomland village but lays down SILT that heals
// the soil, so a riverside field stays fertile under the very cropping that mines an upland field to dust.
{
  const C = Math.round((GRID - 1) / 2);
  const oneVillage = (i, j) => World({ seed: 5, systems: [ makeWatershed(Object.assign({}, WS, { draw: 'settlements' })),
    makeSettlements({ N: GRID, villages: [{ i, j, pop: 80, steward: 1, cropStew: 0.4 }], seed: 5 }) ] });
  const riverside = runYears(oneVillage(C + 1, gi(0.1)), 55).fields().settlements;   // one cell off the river
  const upland = runYears(oneVillage(gi(0.5), gi(0.1)), 55).fields().settlements;    // high on the flank
  ok(riverside.floods > 0 && upland.floods === 0, 'the river floods the bottomland village and spares the uplands (riverside ' + riverside.floods + ' floods, upland ' + upland.floods + ')');
  ok(riverside.meanFert > upland.meanFert + 0.3, 'flood SILT keeps a riverside field fertile where the same cropping mines an upland field to dust (riverside fert ' + riverside.meanFert.toFixed(2) + ' vs upland ' + upland.meanFert.toFixed(2) + ')');
  console.log('  floods: riverside ' + riverside.floods + ' (fert ' + riverside.meanFert.toFixed(2) + ') vs upland ' + upland.floods + ' (fert ' + upland.meanFert.toFixed(2) + ')');
}

// 7. DETERMINISM ----------------------------------------------------------------------------------------
{
  const a = runYears(valleyWorld(0.4, 0.4), 30).fields().settlements.totalPop;
  const b = runYears(valleyWorld(0.4, 0.4), 30).fields().settlements.totalPop;
  ok(a === b, 'same seed -> identical valley (reproducible: ' + a.toFixed(3) + ')');
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);