← Natura·Docs/Modules/Source

commonwealth.js

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

// commonwealth.js -- THE GRAND COUPLING. One running World in which the natural world and the human
// world are wired into a single causal web, so a shock in one place travels downstream through all the
// others with real LAG. This is the capstone the separate exista windows were always pointing at: not a
// set of pretty dials, but a universe where climate drives vegetation drives soil drives harvest drives
// the economy drives population drives disease drives unrest drives a grain-price mania -- each with its
// own delay, each feeding back.
//
// It is a PURPOSE-BUILT coupled world (the pattern that made The Skin of the World and Lives and
// Livelihoods work): the real exista agent-systems where they connect naturally (economy.js, epidemic.js,
// speculation.js), and thin BRIDGE systems (climate, land, harvest, population, mood) where the physical
// coupling is real but two systems share no variable. A `coupler` system runs first each tick, reads the
// last tick's fields, and writes the levers (economy.activity, epidemic.distancing, speculation.V) --
// including SPARKING a latent plague when crowding x malnutrition push the reproduction number past 1, so
// epidemics ARISE from conditions rather than being scripted.
//
// Time: one tick = one WEEK (dt = UNITS.WEEK). The agent-systems scale their rates by dt-in-days
// (epidemic burns over a realistic few-week wave); the bridge systems key off the clock (yearPhase /
// years) so seasons and slow soil-building stay timescale-correct. Deterministic (seeded rng only). UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = factory(require('./natura.js'), require('./economy.js'), require('./epidemic.js'), require('./speculation.js'));
  } else {
    root.Commonwealth = factory(root.Natura, root.Economy, root.Epidemic, root.Speculation);
  }
})(typeof self !== 'undefined' ? self : this, function (Natura, Economy, Epidemic, Speculation) {
  'use strict';
  const { World, UNITS } = Natura;
  const { makeEconomy } = Economy;
  const { makeEpidemic } = Epidemic;
  const { makeSpeculation } = Speculation;
  const TAU = Math.PI * 2, YEAR = UNITS.YEAR;
  const clamp = (x, a, b) => x < a ? a : (x > b ? b : x);
  const relax = (cur, target, dtY, tau) => cur + (target - cur) * (1 - Math.exp(-dtY / tau)); // first-order lag toward target

  // ---------------------------------------------------------------------------------------------------
  // BRIDGE 1: CLIMATE -- the driver at the head of the web. Seasonal temperature + rainfall, a slow
  // multi-year wet/dry oscillation, and a triggerable DROUGHT that decays over ~a year. Everything
  // downstream ultimately answers to this.
  function makeClimate() {
    return {
      name: 'climate', domain: 'atmosphere', scale: 'year',
      init() { return { temp: 12, rain: 0.6, drought: 0, inject: 0 }; },
      step(st, ctx, dt) {
        const yp = ctx.clock.yearPhase(), yr = ctx.clock.years(), dtY = dt / YEAR;
        st.temp = 12 + 13 * Math.sin((yp - 0.30) * TAU);                    // cold winter .. warm summer
        const epoch = 0.58 + 0.24 * Math.sin(yr / 9 * TAU + 1.3);           // ~9-year wet/dry epochs (mild; injected droughts are the big shocks)
        const seasonalRain = 0.62 + 0.42 * Math.sin((yp - 0.12) * TAU);     // wetter in spring
        let rain = clamp(epoch * seasonalRain + 0.05 * (ctx.rng() - 0.5), 0, 1.7);
        if (st.inject > 0) { st.drought = Math.max(st.drought, st.inject); st.inject = 0; }
        st.drought *= Math.exp(-dtY / 0.9);                                 // a drought fades over ~a year
        rain *= (1 - clamp(st.drought, 0, 0.95));
        st.rain = rain;
        return st;
      },
      fields(st) { return { temp: st.temp, rain: st.rain, drought: st.drought }; }
    };
  }

  // BRIDGE 2: LAND -- vegetation (fast) over soil fertility (slow), the lesson of The Skin of the World.
  // Biomass relaxes toward a rain x warmth x fertility target and browns FASTER than it greens; soil
  // fertility builds slowly UNDER biomass and erodes when the ground goes bare.
  function makeLand() {
    return {
      name: 'land', domain: 'biosphere', scale: 'year',
      init() { return { biomass: 0.5, fertility: 0.5 }; },
      step(st, ctx, dt) {
        const dtY = dt / YEAR, cl = ctx.fields.climate || { rain: 0.6, temp: 12 };
        const warmth = clamp((cl.temp - 2) / 22, 0, 1);
        const target = clamp(cl.rain * 0.85, 0, 1) * warmth * (0.55 + 0.45 * st.fertility);
        st.biomass = clamp(relax(st.biomass, target, dtY, target < st.biomass ? 0.06 : 0.18), 0, 1);
        // Soil fertility: humus BUILDS under living cover (saturating), and is lost slowly to mineralization
        // plus fast erosion of GROWING-SEASON BARE ground -- warm-but-not-growing is real degradation (a
        // drought scar), whereas cold winter dormancy (roots present, ground protected) is NOT. So fertility
        // holds high in the good decades and only bleeds out during droughts, recovering slowly after.
        const growBare = warmth * clamp(1 - st.biomass / 0.35, 0, 1);
        const build = 0.08 * st.biomass * (1 - st.fertility);
        const erode = (0.010 + 0.06 * growBare) * st.fertility;
        st.fertility = clamp(st.fertility + (build - erode) * dtY, 0.05, 1);
        return st;
      },
      fields(st) { return { biomass: st.biomass, fertility: st.fertility }; }
    };
  }

  // BRIDGE 3: HARVEST -- grain ripens seasonally (a window around autumn), fills a granary, and is eaten
  // continuously by the population. HUNGER is how far the granary has fallen below a comfortable buffer.
  const PERCAP = 1.0;              // food eaten per person per year
  function makeHarvest() {
    return {
      name: 'harvest', domain: 'agriculture', scale: 'year',
      init() { return { yieldRate: 0, foodStock: 100 * PERCAP * 0.6, hunger: 0, lastAnnual: 130 }; },
      step(st, ctx, dt) {
        const dtY = dt / YEAR, yp = ctx.clock.yearPhase();
        const land = ctx.fields.land || { biomass: 0.5, fertility: 0.5 };
        const pop = (ctx.fields.population && ctx.fields.population.pop) || 100;
        // grain ripens in a pulse around autumn; the pulse is NORMALIZED (the sin^3 lobe integrates to
        // ~0.212 over a year) so `annual` is the whole year's harvest delivered across the season. A good
        // year on fixed farmland yields ~carrying capacity, so a growing population eventually outruns it.
        const ripen = Math.pow(Math.max(0, Math.sin((yp - 0.32) * TAU)), 3);
        // KNOWLEDGE raises the yield of fixed farmland (better ploughs, rotation, drainage): the escape hatch from
        // the Malthusian trap. Level ~0 at the start; as practical know-how compounds the same land feeds more, and
        // a dark age (knowledge lost) pulls the ceiling back DOWN.
        const kn = ctx.fields.knowledge || { level: 0 };
        const know = clamp(kn.level, 0, 4.2);   // 0 at the start; a modern plateau raises carrying capacity to ~5x
        const annual = land.biomass * (0.35 + 0.65 * land.fertility) * 620 * (1 + know);
        st.lastAnnual = annual; st.know = know;
        st.yieldRate = annual * ripen / 0.212;
        st.foodStock += (st.yieldRate - pop * PERCAP) * dtY;
        if (st.foodStock < 0) st.foodStock = 0;
        const buffer = pop * PERCAP * 0.5 + 1e-6;                               // half a year of food = comfortable
        st.hunger = clamp(1 - st.foodStock / buffer, 0, 1);
        return st;
      },
      fields(st) { return { yieldRate: st.yieldRate, foodStock: st.foodStock, hunger: st.hunger, know: st.know || 0 }; }
    };
  }

  // BRIDGE 4: POPULATION -- Malthusian headcount. Births need a fed people (low hunger); deaths come from
  // a baseline, from starvation (hunger squared), and from PLAGUE (the newly-dead fraction of the epidemic
  // network, mapped onto real deaths). Grows in the good decades, crashes in famine and pestilence.
  function makePopulation() {
    return {
      name: 'population', domain: 'demography', scale: 'year',
      init() { return { pop: 100, births: 0, deaths: 0, _prevDead: 0 }; },
      step(st, ctx, dt) {
        const dtY = dt / YEAR;
        const h = (ctx.fields.harvest && ctx.fields.harvest.hunger) || 0;
        const epi = ctx.fields.epidemic || { infectious: 0, dead: 0 };
        const plagueFlux = Math.max(0, (epi.dead || 0) - st._prevDead); st._prevDead = epi.dead || 0;
        const birth = 0.030 * (1 - h);                     // up to ~3%/yr when well fed
        const death = 0.012 + 0.11 * h * h;                // baseline + starvation
        st.births = st.pop * birth * dtY;
        st.deaths = st.pop * death * dtY + st.pop * plagueFlux * 0.6;   // + a plague pulse
        st.pop = Math.max(2, st.pop + st.births - st.deaths);
        return st;
      },
      fields(st) { return { pop: st.pop, births: st.births, deaths: st.deaths }; }
    };
  }

  // BRIDGE 5: MOOD -- fear (from plague + hunger) and UNREST (from hunger + economic contraction, warmed by
  // fear), both slow. High unrest throws a revolt. The society's felt weather, downstream of everything.
  function makeMood() {
    return {
      name: 'mood', domain: 'society', scale: 'year',
      init() { return { unrest: 0.08, fear: 0.06, _revolted: false }; },
      step(st, ctx, dt) {
        const dtY = dt / YEAR;
        const h = (ctx.fields.harvest && ctx.fields.harvest.hunger) || 0;
        const epi = ctx.fields.epidemic || { infectious: 0 };
        const econ = ctx.fields.economy || { activity: 1 };
        const contraction = clamp(1 - (econ.activity != null ? econ.activity : 1), 0, 1);
        st.fear = relax(st.fear, clamp(2.4 * epi.infectious + 0.5 * h, 0, 1), dtY, 0.25);
        const targetUnrest = clamp(0.75 * h + 0.55 * contraction + 0.3 * st.fear - 0.08, 0, 1);
        st.unrest = clamp(relax(st.unrest, targetUnrest, dtY, 0.5), 0, 1);
        return st;
      },
      fields(st) { return { unrest: st.unrest, fear: st.fear }; },
      events(st) {
        if (st.unrest > 0.8 && !st._revolted) { st._revolted = true; return [{ kind: 'revolt' }]; }
        if (st.unrest < 0.5) st._revolted = false;
        return [];
      }
    };
  }

  // BRIDGE 6: KNOWLEDGE -- the slow rise (and loss) of practical know-how that raises the yield of fixed farmland;
  // the ESCAPE from the Malthusian trap. For a long time invention is glacial and every gain is eaten by more mouths
  // (the trap holds), but invention COMPOUNDS (the more you know, the more you can recombine), so a long enough good
  // stretch can finally outrun population -- a takeoff. A catastrophe (famine scatters the skilled, plague kills
  // them) claws it back: a dark age. Real economic history in one scalar.
  function makeKnowledge() {
    return {
      name: 'knowledge', domain: 'mind', scale: 'century',
      init() { return { level: 0, _prevDead: 0 }; },
      step(st, ctx, dt) {
        const dtY = dt / YEAR;
        const econ = ctx.fields.economy || { activity: 1 };
        const h = (ctx.fields.harvest && ctx.fields.harvest.hunger) || 0;
        const pop = (ctx.fields.population && ctx.fields.population.pop) || 100;
        const epi = ctx.fields.epidemic || { dead: 0 };
        const prosperity = clamp((econ.activity != null ? econ.activity : 1) * (1 - h), 0, 1);
        const popF = clamp(pop / 150, 0.15, 2.2);
        // logistic: (0.25 + level) COMPOUNDS invention (more known -> more to recombine, the takeoff), (1 - level/CAP)
        // saturates it at a "modern" plateau instead of running away. Slow start, fast middle, plateau.
        const CAP = 4.2;
        const invent = 0.06 * prosperity * (0.4 + 0.6 * popF) * (0.25 + st.level) * Math.max(0, 1 - st.level / CAP);
        const plagueFlux = Math.max(0, (epi.dead || 0) - st._prevDead); st._prevDead = epi.dead || 0;
        const loss = 0.025 * h + 90 * plagueFlux;                                          // catastrophe erodes know-how
        st.level = Math.max(0, st.level + (invent - loss) * dtY);
        return st;
      },
      fields(st) { return { level: st.level }; }
    };
  }

  // THE COUPLER -- runs FIRST each tick. Reads the previous tick's fields and writes the three levers into
  // the real systems, and sparks a latent plague when the conditions for one are ripe. This is where the
  // web is actually strung: without it the systems merely coexist; with it, a drought becomes a famine
  // becomes a contraction becomes a plague becomes a mania.
  function makeCoupler(cfg) {
    cfg = cfg || {};
    const contactBase = cfg.contactBase != null ? cfg.contactBase : 0.62;
    return {
      name: 'coupler', domain: 'coupling', scale: 'year',
      init() { return { workforce: 1, density: 0.7, transmiss: contactBase, grainV: 100, sparkCooldown: 0, sparks: 0, know: 0 }; },
      step(st, ctx, dt) {
        const F = ctx.fields, W = ctx.world;
        const h = (F.harvest && F.harvest.hunger) || 0;
        const pop = (F.population && F.population.pop) || 100;
        const epi = F.epidemic || { infectious: 0, dead: 0, susceptible: 1 };
        // ECONOMY: the workforce is the living, well, un-sick fraction; hunger drags output down further.
        st.workforce = clamp(1 - epi.infectious * 1.3 - epi.dead, 0.12, 1);
        const act = clamp(st.workforce * (1 - 0.5 * h), 0.08, 1);
        if (W.economy) W.economy.state.activity = act;
        // EPIDEMIC: transmissibility rises with CROWDING (density) and MALNUTRITION (hunger weakens people).
        st.density = clamp(pop / 95, 0.3, 2.4);
        const malnut = 1 + 0.85 * h;
        st.transmiss = clamp(contactBase * st.density * malnut, 0.15, 3.0);
        if (W.epidemic) W.epidemic.state.distancing = st.transmiss;
        // SPECULATION: grain's fundamental value climbs with scarcity, so famine feeds a commodity mania.
        st.grainV = 100 * (1 + 2.4 * h);
        if (W.speculation) W.speculation.state.V = st.grainV;
        // PLAGUE SPARK: a latent pathogen is always present; whether a spark becomes an epidemic depends on
        // the transmissibility the conditions above have produced. Ignites only when the ground is ripe.
        if (st.sparkCooldown > 0) st.sparkCooldown--;
        const ripe = st.transmiss > 0.95 && epi.infectious < 0.006 && (epi.susceptible != null ? epi.susceptible : 1) > 0.35;
        if (W.epidemic && ripe && st.sparkCooldown === 0 && ctx.rng() < 0.12) {
          const S = 0, I = 2, state = W.epidemic.state.state, iT = W.epidemic.state.iT;
          let planted = 0; for (let i = 0; i < state.length && planted < 3; i++) { if (state[i] === S) { state[i] = I; iT[i] = 16; planted++; } }
          if (planted > 0) { st.sparkCooldown = Math.round(3 * YEAR / dt); st.sparks++; }   // no re-spark for ~3 years
        }
        st.know = (F.knowledge && F.knowledge.level) || 0;
        return st;
      },
      fields(st) { return { workforce: st.workforce, density: st.density, transmiss: st.transmiss, grainV: st.grainV, sparks: st.sparks, know: st.know }; }
    };
  }

  // Assemble the world. Coupler FIRST (its lever-writes must land before the real systems step this tick).
  function makeCommonwealth(seed, opts) {
    opts = opts || {};
    const econ = makeEconomy({ N: 40, G: 4 });
    const epi = makeEpidemic({ N: 400, meanDegree: 9, beta: opts.beta != null ? opts.beta : 0.052, incub: 8, infDur: 16, ifr: 0.02, waning: 0.0006, seed0: 0 });
    const spec = makeSpeculation({ V: 100 });
    const world = World({
      seed: seed == null ? 7 : seed,
      systems: [makeCoupler(opts), makeClimate(), makeLand(), makeHarvest(), makePopulation(), econ, epi, spec, makeKnowledge(), makeMood()]
    });
    const dt = UNITS.WEEK;
    return {
      world, dt,
      step() { return world.step(dt); },
      // perturbations the operator can fire and then WATCH ripple downstream:
      drought(strength) { world.get('climate').inject = strength != null ? strength : 0.85; },
      goodYears() { world.get('climate').inject = 0; world.get('climate').drought = 0; },
      plague(n) { const S = 0, I = 2, e = world.get('epidemic'); if (!e) return 0; let p = 0; for (let i = 0; i < e.state.length && p < (n || 4); i++) if (e.state[i] === S) { e.state[i] = I; e.iT[i] = 16; p++; } return p; },
      // a snapshot of the whole web for the viz / test
      read() {
        const f = world.fields();
        return {
          year: world.clock.years(),
          climate: f.climate, land: f.land, harvest: f.harvest,
          population: f.population, economy: f.economy, epidemic: f.epidemic,
          speculation: f.speculation, knowledge: f.knowledge, mood: f.mood, coupler: f.coupler
        };
      }
    };
  }

  return { makeCommonwealth, makeClimate, makeLand, makeHarvest, makePopulation, makeMood, makeCoupler };
});