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