aboutsummaryrefslogtreecommitdiffziptar.gz

worldhistory

Trials of how peoples spread, settle, adapt and change over thousands of years on a planet made with worldgen. Each people ("race") is a population field on worldgen's hexagonal cells: it grows toward what the land can carry, competes with its neighbours, migrates (drift, founder groups, rare long jumps), adapts to depth, gravity, air and temperature, can split into sub-species, and learns (or loses) tech — magic included. Scheduled events (culls, era switches) and hazards (roaming monsters) shape it. Results are trials of algorithms, not a history to take as given: you tune the config and compare runs.

Requirements

  • Python ≥ 3.11, pip install -r requirements.txt (numpy, scipy, pillow, h3)
  • A worldgen build (out/r<res>/cells.npz, optionally eras/<era>/cells.npz)
  • RAM: < 2 GB at res 4 (~290k cells) for the default races; about 10 minutes for 7,500 years.

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python history.py check-config ../myworld/config/history --world ../myworld/out/r4
.venv/bin/python history.py run --world ../myworld/out/r4 --config ../myworld/config/history --trial t1 --seed 1

How it works

One step = 10 years, in this order:

  1. events — scheduled: seed (first placement), cull / die_off (a share of people in a region), era_switch
  2. hazards — static mortality layers; roaming units that raid, breed in clutches and die
  3. capacity — habitat quality × density × tech gain (∝ q²: poor land stays in low gear) × harvest shocks × tolerance fitness × comfort × nudges
  4. growth — logistic with an Allee effect; races share capacity (Lotka–Volterra); optional "veins"
  5. conflict — background war losses by temperament; yielding turns losses into emigration
  6. migration — drift toward headroom, founder groups into empty land, fat-tailed long jumps; with [demography] current_bias > 0 (default 0; e.g. 1–3 for swimmers) sea jumps and drift between sea cells favour going with the ocean current
  7. adaptation — each condition's optimum moves toward where people live; the widths are fixed, so the old is lost; past the race's adaptable range [lo, hi] drift is braked (fuzzy, not a line)
  8. sub-species — a group's progress toward a related race is read from its adapted optima (weighted over the conditions where the two races' native curves differ); a birth (once per sub-species) resets the group to the new race's curves; displacement squeezes the less fit of the pair in shared cells; parent groups adapted past mix_min bear the dominant race; adapted-back groups give returns (parent-race births, any number); mode = "ritual" births by chance and wins converts at a blood price
  9. tech — domains (farming, crafts, travel, magic, war, land) grow with connected population, lost when isolated

Config

A config dir holds history.toml (run length, regions, events, hazards, nudges, tech and migration parameters) and races/<race>.toml (demography, seed, habitat terms and gates over named predicates, travel costs, tolerance envelopes, temperament, conflict, tech priorities, sub-species emerge rules). Unknown keys are errors.

Tolerances: optimum, width (or width_lo / width_hi for a lopsided bell), lo, hi (adaptable range, soft), rate, comfort. Conditions: depth, air_pressure, gravity, o2, temperature, rain (log10 mm/yr; no effect at sea). Strain: an adapted curve's peak is 0.35^(x²), x = (optimum − native) / (range edge − native) — adapted groups are livable, not good; a comfort list ([[value, peak], …]) replaces this for its condition. Displacement compares fit × comfort. A sub-species inherits the parent's curves per condition and overrides the ones that define it.

[emerge] (defaults): parent, mode ("adapt" | "ritual"), birth_min 0.4, birth_sure 0.8, birth_rate 0.2 (chance per group per step at birth_sure, × P/(P+founder)), mix_min 0.3, dominance 1, birth_shift 1, displace 0.9, return_min / return_sure (= birth values), convert 1, region, condition, settlement_density, isolated, spread ("contact" | "region"); ritual only: trigger 0.01, ritual_rate 0.02 (rituals per step per √person), ritual_cost 300 deaths, ritual_converts 30, victims ("parent" | "all": the dead come from every nearby non-ritual people by presence), crisis_drop 0 (off; > 0: birth only where parents fell to ≤ (1 − drop) of their recent peak, crisis_min 1000 people, memory crisis_years 200; crisis_km 0: the survivors are born into the nearest cell within that range where the new race can live, 0 = the crisis cell itself must fit), backlash_victims 0 (off; after that many dead in all, one smackdown: the race loses backlash_loss 0.7 and holds rituals at backlash_calm 0.2 × the rate), raid_rate 0 (off; before the backlash, groups of ≥ raid_min 1000 send ~Poisson(rate) cells of raid_size 100 to occupied non-ritual land within raid_km 300; stats raids), ritual_power 0.5 (rituals ~ Poisson(rate · P^power); 1 = every cultist sacrifices), backlash_years 0 (off; the smackdown also fires this many years after the birth). The smackdown's losses fall by exposure (own people + non-ritual people in and next to the cell): big exposed groups are wiped out, small remote ones survive. Any mode: after 0 (earliest birth year), return_rate (= birth_rate). The old class / years keys and [exposure] are gone (errors name the replacement). Full configs live in the world project that runs the sim (e.g. its map/config/history/).

Ocean fields from newer worldgen builds (current, productivity, upwelling, sst) load when present; older builds behave as still water. The habitat predicate productivity (0–1 sea life) can weight a race's terms. World.sail_cost(ship_ms) gives hours per directed sea edge (still-water ship speed helped or slowed by the current) for route work; the engine does not use it yet.

CLI

history.py run --world DIR --config DIR --trial NAME [--seed N] [--years N] [--out DIR] [--no-preview]
history.py preview TRIAL_DIR                  # PNGs into TRIAL_DIR/preview/
history.py compare TRIAL_A TRIAL_B [--out DIR] # seed agreement: same dominant race per occupied cell
history.py check-config CONFIG_DIR [--world DIR]

Long runs (> 10 min or > 2 GB): reserve and cap via the shared ledger, e.g. wf res run --mem 4G --for 30m --title "history t1" -- python history.py run … (busy → exit 3, retry later).

Output

<world>/history/<trial>/: run.toml (everything resolved, seed, engine version), snap/y<year>.npz (sparse population, adapted optima, tech per race and cell), stats.json (per-step totals, regions, progress toward each sub-species, events, emergence, returns, rituals, hazards, timing), preview/*.png.

Layout

history.py        CLI
worldhistory/     engine modules (one per phase; engine.py drives them)
tests/            python -m unittest discover -s tests -t .

Licence and credits

MIT, see LICENSE. Third-party notices: licenses.md. Credits and the models used: CREDITS.md.

From the author

These projects are things that have been tumbling about in my head for a long time, and I now feel like trying to do something with. The bulk of my focus here is on some of the games that I love, but every time I boot them up, I just fiddle about in the menu and set up mods and fixes for a couple hours, with my drive to play them fizzling out. This is my hope to solve that, and while I am a professional software engineer this would not have happened were it not for the rise of (relatively) cheap AI that could do the bulk of the work with me. If you don't approve, that's fine. I did these things for me, sharing them is something I do in the hopes to help others in similar situations, that just want to play the games they love. Thank you to all that made these playable in the first place, with some luck this finds you, and can bring some joy.

Written with AI assistance (Claude, by Anthropic), used as an engineering tool.