planetsim/docs/design-notes.md
Jonas Reith e27b91a128 Civ Step 7: trade & economy
Settlements no longer prosper on local food alone. A derived economic layer:
trade routes link nearby settlements, prosperity accrues at hubs, and prosperity
boosts population growth. Like territory/culture it's a pure function of the
(saved) settlements + geography + wars/diplomacy -- recomputed each sim year,
NO new saved state and NO save-version bump.

- PlanetTrade.{hpp,cpp}: computeTrade() builds TradeLinks (sea when both coastal
  -> longer reach / river / overland) with volume from populations x proximity x
  a political factor (blockaded by war, boosted by an alliance); prosperity =
  base + sum of link volumes (hubs get rich); a per-cell wealth field via a new
  sCellSettleOwner filled by computeTerritory.
- Prosperity feeds carrying capacity in stepCivilization (K *= 1 +
  tradeProsperityWeight*prosperity), so connected coastal/river hubs grow bigger.
- Light feedback in stepConflict (reads last year's economy): trade partners get
  a diplomacy attitude nudge (reward alliance); a wealthy weak neighbour raises
  war hostility (tempt war).
- Render: a Wealth heat map (wealthColor) + trade-route overlay (sea cyan /
  land+river amber) under key Z, a gold wealth dot per settlement in the Civ tab,
  a cell-info prosperity/route line. trade* config knobs (no save block).
- test_trade.cpp: links form (sea over longer range), hubs richer than isolated,
  war blocks trade, prosperity raises carrying capacity, determinism, RNG
  isolation, save->load->recompute parity. All 15 suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:36:41 +02:00

551 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design notes (durable context)
These are the non-obvious decisions/conventions that were previously only in Claude's
auto-memory (which lives under `~/.claude/` and does **not** travel with the repo). Captured
here so the context survives a move to another machine/server. `CLAUDE.md` has the
authoritative current-state changelog; this is the "why / where things live" summary.
## Framing: World Creation → Live World
The roadmap is no longer rigid numbered "phases". **World Creation** is a set of
continuous, overlapping stages on a geological clock (My): tectonics → continental drift &
erosion → hydrology → climate → biomes → (fauna & flora, next). The long-term goal is a
separate **Live World** mode that runs the *finished* planet at a much slower real-time
clock (hours/days/weeks/months) with dynamic weather (clouds, rain, storms) and living
ecosystems/civilization. **Internal code still uses `phase*` names** (`Planet::drifting`,
the `phase3` flag, `phase3AfterMy`/`phase3DtScale` config keys) for save/config
compatibility — only display strings and docs use the new framing.
## Code module layout
Split into a raylib-free **engine** (`src/sim/`, headless-testable) and a raylib **viewer**
(`src/render/`); `src/main.cpp` is a ~10-line entry point. CMake adds both dirs to the
include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
`Planet` is **one class implemented across several .cpp files** (all share `Planet.hpp`):
- `PlanetTypes.hpp``Cell` / `Plate` / `SubGrid` / `Biome` enum / `PlanetConfig`.
- `Planet.cpp` — generation, geometry, plate seeding, RNG + shared helpers, subgrid, min/max.
- `PlanetTectonics.cpp``step()` (stress→uplift→relax; orogeny boosts gated on `drifting`).
- `PlanetDrift.cpp``cflDtMy`/`advect` + plate lifecycle (fission/kick/baby/fuse/enclosed).
- `PlanetErosion.cpp``erode` + `adjustSeaLevel`.
- `PlanetHydrology.cpp``routeFlow`/`computeHydrology`/`hydrology` (depression-fill→lakes,
steepest-descent→rivers, mass-conserving stream-power incision).
- `PlanetClimate.cpp``computeClimate()` (temperature + orographic precipitation).
- `PlanetLive.cpp``computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live
seasonal temperature; derived, not saved).
- `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) +
`computeTides()` + `computeOceanCurrents()` (Live World sky, tides & currents). Moons saved
(v9); tides/currents derived.
- `PlanetWeather.cpp``initWeather`/`stepWeather` (Live World dynamic humidity/cloud/rain cycle;
saved v10).
- `PlanetBiomes.cpp``classifyBiomes()` (per-cell `Cell.biome` from elevation + climate).
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
- `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density +
per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led).
- `PlanetVolcano.cpp``placeVolcanoes`/`stepVolcanoes` (Live World stateful volcano lifecycle +
islands; saved v15).
- `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc).
- `PlanetGeography.{hpp,cpp}``generateGeography()` (named features: continents/oceans/ranges/
rivers/lakes; the atlas, saved v17+).
- `PlanetEcoregions.{hpp,cpp}``generateEcoregions()` (named ecological provinces from biome,
land/water context, productivity and broad biota; saved v19).
- `PlanetCiv.{hpp,cpp}``computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2:
habitability + settlements that grow/decline on the live clock; saved v20).
- `PlanetNation.{hpp,cpp}``computeTerritory` (civ Step 3: realms + per-cell ownership; derived, not saved).
- `PlanetCulture.{hpp,cpp}``computeCultures` (civ Step 4: cultures/ethos/faith per continent +
government per realm; derived, not saved).
- `PlanetConflict.{hpp,cpp}``stepConflict` (civ Steps 5-6: wars/conquest/revolts + diplomacy/
alliances/coalitions; stateful, saved v21/v22 + snapshotted).
- `PlanetTrade.{hpp,cpp}``computeTrade` (civ Step 7: trade routes + prosperity/wealth; derived, not saved).
- `PlanetIO.cpp` — text config + binary save/load.
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
`ViewerInput.cpp` (camera/picking/keys), `ViewerRender.cpp` (globe/map/panels/HUD/prompt),
plus topical helpers `Colors` / `Map2D` / `Overlays` / `Picking` / `Panels`.
Per-tick order in `Viewer::refreshView()`: `computeHydrology()` (if hydrology on) →
`computeClimate()``classifyBiomes()``computeBiotaDensity()``recolor()`. The discrete
biota *population* (`generateBiota()`) is NOT in this per-tick path — it's on-demand (key `L`).
## Core principle (do not violate)
Geometry is **fixed** — cells (icosphere vertices) never move. Only per-cell *properties*
flow over the fixed grid + neighbor adjacency (Eulerian). New phenomena = new per-cell
fields flowed over the grid, never moving cells.
## Axial tilt render convention (non-obvious)
The 3D globe is rendered leaned by `cfg.axialTilt` via `rlRotatef(tilt,0,0,1)` wrapping all
3D content in `renderGlobe3D`. Because that rotation isn't in the data, anything mapping
between world and model space must compensate with `rotateZ(v, ±tilt)` (src/render/
Picking.cpp): 3D picking un-rotates the ray hit by `tilt` before `nearestCell`; 3D plate
labels rotate by `+tilt` before projecting. The 2D map + biome/climate are tilt-independent.
## Save format (v7) — self-describing config + biota population
`planet.save` stores `PlanetConfig` as a **self-describing key=value text block** (not a raw
POD dump), parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared in
PlanetIO.cpp), written at `precision(17)` so doubles round-trip exactly. Consequence:
**adding/removing PlanetConfig fields no longer breaks saves** (unknown keys ignored, missing
keys keep defaults). v6 cannot load pre-v6 saves (one-time break; a length guard fails it
gracefully). Per-cell `Cell.biome` is saved (a byte appended after `invader`). **v7** appends
the **biota population** (`sBiota`): a flag byte, then three `Organism{uint16 archetype, uint8
biome}` lists per cell. Densities are derived (not saved). Older saves without the block load
fine with an empty population (`readState(is, hasBiome, hasBiota)`; `hasBiota = ver>=7`).
## Biota (flora / fauna / funga) — density + slot/point population
Two layers (`PlanetBiota.cpp` + the three `*Gen.cpp`): (1) derived per-cell **density** scalars
(0..1) recomputed each tick like climate — flora = Liebig-min(temp, moisture), fauna ∝ flora
(carnivores gated on neighbourhood prey ≥ `bioCarnPreyMin`), funga = moisture/organic-matter-led
+ cold-tolerant. All three are 0 under polar `Ice`; funga is also 0 on water, while flora/fauna
extend into the ocean as marine productivity (see below). (2) On-demand discrete **population**
`generateBiota()`: each land **or ocean** cell draws broad archetypes from the comprehensive
append-only `biotaArchetypes()` table
into a per-kind slot cap + density-scaled point budget (size → cost Tiny=1…Huge=5), weighted by
biome/climate suitability and a **regional bonus** for archetypes already in same-biome
neighbours (single index-ordered pass → homogeneous regions, boundary variety). Organisms are
labelled by **taxonomy** — Family + Size + role (full `Class > Order > Family` in
`organismTaxonomy()`), never informal common names ("Felidae", not "cat"); generalist families
get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seeded from `cfg.seed`** (not
`Planet::rngState`) so populating biota never perturbs tectonic determinism — asserted in
`test_biota.cpp`. The archetype table is **append-only** (indices are serialized in v7 saves).
**Marine flora & fauna (life in the ocean).** Funga stays land-only, but flora and fauna now
extend over water (the old hard `elevation<=sea` gate left the sea barren). Ocean cells (not under
polar `Ice`) get a marine primary productivity in `computeFloraDensity`:
`base + (1-base)·max(shelf, coast)`, where `shelf = clamp(1 - depth/bioMarineShelfDepth)` (light to
the photic floor) and `coast = clamp(1 - ringDistFromLand/bioMarineCoastRings)` (land-runoff
nutrients, a multi-source BFS ring-distance seeded from land — the continentality BFS mirrored).
`sMoist` is a land rainfall field and is not used at sea. `computeFaunaDensity` now skips only `Ice`,
so marine fauna = `flora·productivity` and the carnivore prey-gate clusters sharks/seals/squid on
rich shelves. `generateBiota` fills ocean cells too (skip `Ice`; no marine funga); `fillFlora`/
`fillFauna` are unchanged because their biome-mask filter draws only the **Ocean-masked** archetypes
appended to the table (Kelp/Seagrass/Phytoplankton; Forage fish/Reef fish/Shark/Baleen whale/Seal/
Squid — all `moistMin=0`, SST-zoned). The flora/fauna colour views render ocean on a distinct
marine ramp (`marineFloraColor`/`marineFaunaColor`). Knobs: `bioMarineBase`/`bioMarineShelfDepth`/
`bioMarineCoastRings`. No save bump (densities derived; archetypes append-only; config
self-describing).
## Climate + biome model (derived, not saved)
`computeClimate()` builds two derived per-cell fields:
- **Temperature** (°C) = latitude curve (`biomeEquatorTemp/PoleDrop/LatExp`, super-linear so
cold concentrates at poles) `biomeElevLapse` × elevation. This is the **annual mean**; the
**Seasons** pass adds derived `sTempSummer`/`sTempWinter` = mean ± `A`, where the seasonal
half-amplitude `A = seasonAmpMax · sin(axialTilt)/sin(23.44°) · latShape · continentality`.
Continentality is a multi-source-BFS ring distance from ocean cells (oceans/coasts muted by
thermal inertia; interiors swing most). `classifyBiomes()` blends winter temp into the
Tundra/Taiga cold cutoffs via `biomeSeasonWeight` (0 = mean only → unchanged biomes), so
cold-winter continental interiors turn boreal/tundra. Seasonal fields are derived/not-saved.
**Ocean currents** add a bounded coastal warm/cold anomaly to this mean before the seasons pass
(`climateCurrentFactor`; see the Ocean section).
- **Precipitation**: zonal prevailing winds (easterly tropics/poles, westerly mid-lat); ocean
cells are a moisture source; each land cell takes its **upwind** neighbour's moisture, rains
out more on windward upslopes (orographic), loses a multiplicative fraction per cell
(continentality) → leeward/interior drying. The raw field is near-binary, so it's
**diffused** (`climateMoistureSmooth` passes) into transition zones, then normalized to
`sMoist∈[0,1]` by anchoring the **median land precip → 0.5** (robust to orographic spikes).
`classifyBiomes()` reads `sTemp` + `sMoist` (not a latitude hack) → rain-shadow/interior
deserts emerge; 13 biomes incl. polar Ice; wetlands require water adjacency. All biome &
climate thresholds are tunable `biome*` / `climate*` keys in `planet.cfg`.
## Live World (slow real-time clock) — day/night + live seasons (derived, not saved)
The arc after World Creation: the finished planet runs on a slow real-time clock instead of
the geological My clock. `PlanetLive.cpp` (raylib-free) builds two derived per-cell fields,
recomputed each frame like climate (never saved):
- `computeInsolation(dayOfYear01, timeOfDay01)``sInsolation` ∈ [0,1], the instantaneous
solar incidence `max(0, cell.unit · sunDir)`. `sunDir = lonLatToDir(λ, δ)` with declination
`δ = axialTilt·sin(2π·dayOfYear01)` (0 at equinox, ±tilt at solstice → polar day/night) and
sub-solar longitude `λ = π·(12·timeOfDay01)` sweeping once per day. **This is the hook the
future weather sim reads** (daytime heating). Computed in **model space** (the fixed cell
units) so it stays consistent with both the tilted 3D globe (the lit pattern rotates with the
globe; the seasonal lean is carried by `δ`, not the render tilt) and the model-space 2D map.
- `computeLiveSeason(dayOfYear01)``sLiveTemp`, the annual-mean `sTemp` swung toward the
static `summerTemp`/`winterTemp` by the seasonal phase `g = sin(2π·doy)·sign(lat)`
(`liveTemp = mean + A·g`, `A = (summerwinter)/2`), anti-phased across hemispheres.
Viewer (Eulerian, geometry fixed — all overlays are per-cell render passes): key `W` (settled
world) toggles `liveWorld`; drift freezes and `liveTime` (hours) advances at `liveRate` (sim
hours/real-second, ramped hour→month with `[`/`]`). `rebuildLiveOverlay()` builds `illum` (soft
day/night terminator over `sInsolation`, dim night floor) + `shadedColors` (base colour → snow
on cold land / sea-ice on cold ocean via `snowTemp`/`seaIceTemp` → day/night dim); both the 3D
globe and 2D map draw `displayColors()` (the overlay over **any** colour mode). `N` toggles the
terminator. Save **v8** appends a Live World flag + `liveTime` (header, version-gated). Knobs:
`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp` in `planet.cfg`.
## Moons & tides (Live World sky/oceans)
`PlanetOcean.cpp` (raylib-free): `generateMoons()` seeds **13 `Moon`s** from a **separate RNG**
(`cfg.seed ^ 0x900D5EED`) so it never touches the tectonic `rngState` — moons are world objects
(not cells) and are **saved (v9)** via `writeState`/`readState(..., hasMoons)` (pre-v9 saves
synthesize them from the seed). Sky geometry is one source of truth: `sunDirection(doy,tod)` =
celestial dir leaned by declination then spun `-2π·tod` about +Y; `moonDirection(i,tod,days)` =
inclined orbit circle `Ω=2π·days/period+phase` then the same spin (so a fixed cell sees ≈one
lunar pass/day). `computeInsolation` now calls `sunDirection`. **Tides** (`computeTides`
`sTide`, derived/not saved): equilibrium two-bulge potential `Σ_body w·(cosθ²⅓)` over the moons
(weight `tideWeight`) + sun (`tideSunFactor`), scaled `tideAmplitude` — zero-mean, high under a
body and its antipode, low at 90°, sweeping ≈twice/day.
Render (Viewer): the coastline is traced once per terrain change (`buildCoastline`, dual-contour
on the land/ocean split, recording the adjacent ocean cell per segment) and coloured by
`tideColor(sTide[oceanCell])` (`T`; auto-scaled), in 3D + 2D. The 3D sun is small/distant with a
halo; moons render at a visible orbit band with a sun-lit **phase** (offset-dark-sphere trick),
faint **orbit rings** (great circle ⟂ `moonOrbitNormal`), and **eclipses** — solar darkens a spot
in `rebuildLiveOverlay`'s `illum` near the sub-solar point when a moon transits the sun; lunar
dims a moon reddish in the planet's shadow.
**Ocean currents** (`computeOceanCurrents`, also `PlanetOcean.cpp`): a per-ocean-cell tangent
velocity `sCurrent` from wind stress (`sWind`) rotated by a **Coriolis** deflection (right N /
left S about the cell normal), with the across-shore component removed at land neighbours so the
stream follows the coast (gyres), then smoothed and re-tangented (zero on land). `computeClimate`
calls it right after the wind pass and feeds **warm (poleward) / cold (equatorward)** currents
back into `sTemp` as a bounded coastal anomaly (`climateCurrentFactor`, smoothed onto coasts,
applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea
(`buildCurrents`, key `O`). Currents/feedback are derived (not saved).
## Weather (Live World dynamic clouds & rain)
`PlanetWeather.cpp` advances a per-cell **humidity / cloud / rain** cycle on the live clock
(`stepWeather(dtHours)`), time-varying unlike the static climate. One step: **evaporate** over
warm sunlit ocean (relax humidity toward a marine target scaled by `sTemp` warmth + `sInsolation`
daytime), **advect** humidity & cloud downwind (upwind differencing along `sWind`/`sUpwind`, speed
`weatherWindKmh`), **condense** the supersaturated air into cloud (saturation
`weatherSatBase + weatherSatTempCoef·T`, plus windward orographic lift), **rain** out cloud above
`weatherRainThresh`, then **dissipate** (half returns to humidity). All rate terms use bounded
`1exp(rate·dt)` forms so it's stable at any timestep (the clock can run hours→months/sec).
`initWeather()` seeds it from the moisture climatology. Deterministic (no RNG). Driven each live
frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when paused).
Render: a translucent **cloud shell** over the 3D globe (white → dark slate where it rains, alpha
= cover, a second triangle layer at `visBase+0.03`) and a matching `drawWeather2D` layer on the
2D map (shared `drawMapTris` rasterizer), toggled with `K`. Saved as **v10** (humidity/cloud/rain,
flag-gated; pre-v10 saves spin weather up on entering Live World).
**Moving weather systems** (same `stepWeather`): the base field above relaxes to a *static*
pattern under fixed forcing, so a population of drifting `WeatherSystem` **agents** (world objects,
not cells — like moons; transient/not saved; separate `sWeatherRng` seeded from `cfg.seed`)
provides the motion. Each step they **spawn** over warm tropical ocean (525°) or a mid-latitude
(3062°) ocean low, **move** along the steering wind (`sWind` at the nearest cell) + a poleward
recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** over land/cold, and
**stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate
behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as
animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`.
The systems (+ their RNG/next-id) are **saved (v11)** alongside the humidity/cloud/rain fields, so a
load resumes active storms; a load also drops any stale pre-load `wxUndo` step-back ring and (v12)
restores the most recent `wxSaveMax`(40) step-back frames from the file, so stepping back after a load
can rewind storms past the saved moment. v13 also saves the Live World clock rate. Weather is
integrated/path-dependent, so reversing it past a save is only possible via this stored history — it
can't be re-derived from the loaded moment.
## Volcanoes & volcanic islands (Live World)
`PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`) and a stateful lifecycle
agent. On **entering Live World** `placeVolcanoes(liveTime)` seeds a set once, by tectonic context: a cell is
**ridge** if its plate is `baby` or a neighbour's is (the `buildBorders` baby test), else **border**
if a neighbour has a different `plateId`, else **interior**; placement probability is
`volcanoProbRidge``volcanoProbBorder``volcanoProbInterior`, reservoir-sampled to
`volcanoMaxCount` (an unbiased subset, ratios preserved). A separate RNG (`sVolRng = cfg.seed ^
0x70C4F12A`) keeps the tectonic stream untouched. Each vent stores `baseElev`, current `built`
height, `phase` (growing/dormant), dormancy and ash timers, ash carry, and decaying `activity`.
The crucial design point is now the opposite of the original v14 implementation: **volcanoes are
integrated forward**, not pure functions of `liveTime`. `stepVolcanoes(dt)` grows active vents by
`volcanoBuildRate * activity`, lets tall vents go dormant above `volcanoFreeHeight`, explodes dormant
vents after a long timer, shaves `volcanoExplodeDropFrac` of built height, starts sustained ash
emission, and decays activity so old volcanoes settle. It always reasserts
`cells[v.cell].elevation = baseElev + built`; submarine vents crossing sea level breach into islands
and can re-submerge when a restored snapshot has less built height. Explosions stamp a wide local ash
blast into `sCloud`/`sHumidity` and sustained puffs continue while `ashTimer` runs.
Because this is stochastic state, **step-back snapshots include volcanoes + `sVolRng`** alongside
weather. A backward step restores weather, storms, volcano lifecycle state and RNG, then
`stepVolcanoes(0)` reasserts terrain without advancing. Rendered as growing red/orange cones,
dormant grey quiet cones, and bright post-explosion plume markers (3D + 2D, key `V`). **Saved v15**:
the stateful `Volcano` set + `sVolRng`; v14's old pure-function block is consumed and discarded so
volcanoes reseed on the next Live World entry. Knobs: `volcano*`.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
Three viewer-only controls over the Live World sim:
- **Storm follow-cam** (`Y`): the globe is at the origin and the camera orbits it, so to centre a
storm we point the camera **along the storm's world direction**`rotateZ(storm.pos, +axialTilt)`
(model→world; `Picking.hpp`), then `camPitch=asin(d.y)`, `camYaw=atan2(d.x,d.z)`. Tracked by a
stable `WeatherSystem.id` (assigned at spawn; transient, no RNG/determinism impact). Orbit-drag is
disabled while following; wheel-zoom still works; cycles by descending strength, auto-releases if
the storm dissipates.
- **2D map zoom** (`mapZoom`/`mapPanX`/`mapPanY`): implemented as a **virtual projection rect**,
`Viewer::mapViewRect()` = `mapRect` scaled about its centre + pan. Every map projection call
(`drawMap2D`/`drawWeather2D`/`drawSegments2D`/graticule/markers/`mapScreen` + the 2D hover-pick)
takes this `vr` instead of `mapRect`, while the **scissor + frame stay `mapRect`** so it clips to
the panel — no Map2D signature changes. `drawMapTris` was changed to derive the y-coordinate from
the rect (not the fixed-to-mapRect `m.pos`) so both axes zoom. Wheel zooms toward the cursor (18×);
drag pans when zoomed, else keeps the `mapLon` longitude rotation.
- **Clock stepper**: the `stepSim` Live-World body is factored into `Viewer::liveAdvance(dtClock,
dtWeather)` (clamps `liveTime≥0`, recomputes insolation/season/tides/moons, `stepWeather`,
overlay). `.`/`,` step ±`liveRate` hours and **auto-pause** (frame-step). Weather is integrated
and not analytically reversible, so a forward step snapshots the full weather state
(`Planet::captureWeather`/`restoreWeather` — humidity/cloud/rain/storms/RNG) into a bounded
`wxUndo` ring; **`,` restores the newest snapshot at or before now**, reversing clouds/rain/storms
exactly as well as the deterministic sky. `liveAdvance` records a snapshot at ~one-step cadence on
*any* forward advance — continuous run or manual step — so storms born during a run also rewind
(the ring is bounded, ~one snapshot per real second since the interval scales with `liveRate`). The
restored snapshot includes the storm RNG, so re-stepping forward replays deterministically.
## Live World event journal
`Viewer` owns a saved, bounded event journal (`WorldEvent`, newest 200) shown in the tabbed
`liveInfoRect` panel beside the 2D map (`Sky` / `Tides` / `Weather` / `Events`). It is intentionally
viewer-level state: the sim emits no UI strings, and the log is **not** part of step-back history.
Rewinding restores weather/storms/volcanoes, but the journal remains the observer's record.
Events are detected in `Viewer::liveAdvance` by comparing before/after Live World state: weather
system formation, tropical systems crossing hurricane/typhoon strength, volcano dormancy, dormant
volcano eruptions, and submarine volcanoes breaching into islands. Clicking an event calls
`focusCell`: select/rebuild the cell detail, release storm follow-cam, rotate the 3D camera to the
cell using the same axial-tilt convention as picking, and centre the 2D map at the current zoom.
Save **v16** appends the event log; pre-v16 saves load with an empty journal.
## Geography & place-names — the atlas (civilization Step 1)
`PlanetGeography.cpp` + `NameGen.cpp` (engine, raylib-free, deterministic). The first step of the
civilization arc: name the world so everything civic can reference it. `Planet::generateGeography()`
extracts geographic features from the frozen terrain purely by **connectivity over the fixed grid**
(the same flood-fill idiom as `coalesceBabyPlates` / the enclosed-sea fill): connected land →
**Continent** (≥ `geoContinentMinCells`) or **Island**; the connected world ocean is split into
**basins** (a single connected body reads wrong as one name) by a distance-from-land watershed —
greedy farthest-first deep-water **centres** (`geoOceanSepRadians` apart, ≥ `geoOceanDeep` rings from
land) then multi-source BFS Voronoi over the ocean graph — each basin → an **Ocean** (or **Sea** if ≤
`geoSeaMaxCells`); inland filled basins (`lakeDepth`) → **Lake**; connected `> geoMountainElev` land →
**MountainRange** + its highest cell as a **Peak**; the largest `discharge` mouths traced upstream via
`flowTo` → **River**. It first calls `computeHydrology()` (routing only — no elevation change) so the
river/lake fields exist on a finished world.
Naming is a separate concern in `NameGen` (syllable banks; `bankForRegion` gives each continent a
"language" so its rivers/mountains share a sound) and uses a separate RNG (`sGeoRng = cfg.seed ^
magic`) + a per-feature hash, so it is deterministic and **never perturbs the tectonic stream**
(asserted in `test_geography.cpp`). Output: `Planet::geoFeatures` (id/kind/name/anchorCell/regionId/
size) plus four per-cell index arrays (`sCellLand`/`sCellWater`/`sCellRange`/`sCellRiver`) giving O(1)
"which features is this cell in" — the hook the later territory/border step will build on. Geography is
static (terrain is frozen), so it is generated **once** on a settled world (key `M`, in or out of Live
World) and **saved (v17+)** — names persist so a future culture step can rename places. The viewer draws
names as labels on the globe (the plate-label manual projection) + 2D map (minor features only when
zoomed, to declutter), lists them in an **Atlas** tab (5th live-info tab; click a row → `focusCell`),
and adds a "region" line to cell-info. Save v17 appends the feature records (with `std::string` names,
written field-by-field) + the POD index arrays; pre-v17 saves load with none and regenerate on demand.
Save v18 appends the active geography reshuffle salt (`Shift+M`) so repeated renames continue after
load.
Names dedupe on the **proper-noun root** (not the formatted string), so a continent, its river and its
mountains can't share a base name. New land created during Live World (a volcanic island breaching the
sea) is added to the atlas on the fly by `Planet::nameNewLand(cell)` — it joins an adjacent existing
landmass or mints a fresh unique Island name, which the island-formation `WorldEvent` then carries.
## Ecoregions — named ecological provinces
`PlanetEcoregions.cpp` (engine, raylib-free, deterministic) adds the next atlas-like layer after
geography. `Planet::generateEcoregions()` ensures climate/biomes, biota density, hydrology and
geography exist, then flood-fills connected cells by **biome + land/ocean/wet context + similar
productivity band**. Tiny fragments merge into an adjacent compatible region when possible. Each
`Ecoregion` stores a name, biome, anchor cell, containing geography feature id, size, average
flora/fauna/funga productivity and dominant broad flora/fauna/funga archetype. If the discrete biota
population exists (`L`), dominants come from the actual placed organisms; otherwise they are inferred
from density + archetype suitability. Ecoregions summarize existing broad ecology — they do not create
new species or a food-web simulation.
Names use `NameGen` with the containing geography bank, so ecological names inherit regional sound
without touching the tectonic RNG. Viewer key `E` lazily generates/toggles the ecoregion colour view,
cell-info shows the local ecoregion and dominants, and Live World has an **Eco** tab (6th tab; click
a row → `focusCell`). Save **v19** appends the ecoregion records and `sCellEcoregion`; pre-v19 saves
load with none and regenerate on demand.
## Civilization Step 2 — habitability & settlements
`PlanetCiv.cpp` (engine, raylib-free, deterministic, separate `sCivRng`). `computeHabitability()` is a
derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access
(river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion
productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn")
seeds a **fixed** set once by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`)
with a **soft Gaussian suppression** (`civMinSpacingRadians`) softening nearby weights after each pick — so
settlements **cluster** on good land at irregular spacing instead of an even lattice (the earlier hard
farthest-first looked like a grid). Each is named from its continent's `NameGen` bank.
Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state
is each settlement's **population** — which is all the step-back snapshot stores (a `vector<double>` in
`WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours,
liveTime)` runs in `liveAdvance` after `stepVolcanoes`. It is **environment-driven and dynamic** (the
first cut grew every town uniformly to the same cap):
- growth **rate** `r = civGrowthRate·(civGrowthMin + (1civGrowthMin)·habitability)` so fertile cells
grow far faster than marginal ones;
- carrying capacity `K = civMaxPopulation · habitability · siteQuality · conditions`, where
**siteQuality** = `0.45 + civSiteVariety·(coastBonus + log10(1+discharge/20))` makes max city size vary
by an order of magnitude (a continental river or coast → a metropolis, a dry inland cell → a town —
this is what spreads final sizes instead of all saturating equally);
- **conditions** = `harvest · drought · coldYear · flood · ash`, all **deterministic functions of
(≈20° region bucket, integer year, seed)** — constant within a year, region-correlated, recomputed
on a step-back (pure, so no extra saved/snapshot state): year-to-year harvests (swing scaled by the
seasonal-amplitude/continentality field), multi-year droughts (a slow noise interpolated across
`civDroughtPeriod`-year epochs, threshold raised by aridity `1sMoist`), rare cold years (× the cell's
near-freezing-winter exposure), river floods (silt bonus / rare disaster), and the volcano-ash cut.
- **Storms** read live `storms()` (already snapshotted): a system within its `radius` of a town deals
direct deaths `civStormDeathRate·strength·overlap·(hurricane? civHurricaneDeathMult)` — a parked
hurricane can gut a coastal city.
- logistic step + an accelerated `civFamineRate` loss when `K<P` + the storm deaths; floored at 1 so a
site revives. So towns **grow, fluctuate, shrink in droughts, and collapse/abandon** under sustained
famine or an acute disaster.
Per-settlement derived `sCivCond` (combined multiplier) + `sCivDrought` (severity) drive the viewer:
markers are **withered-tinted** by hardship, cell-info shows "drought/conditions", and `detectLiveEvents`
logs kind=3 events with the **cause** — a storm over the shrunk town → "Hurricane <name> devastates X"
(reuses `weatherEventName`), else "Famine shrinks X to a Town" when `sCivDrought` is high, else tier
up/down/abandon. Markers (3D spheres + 2D dots, sized by tier; city/town 3D labels), a **Civ** tab (7th),
a cell-info line, and a `Habitability` colour mode (key `I`). `buildGeometry()` clears the set on reseed.
Save **v20** stores the settlement records (population included); `sCellSettlement` is rebuilt on load.
Knobs `civ*`.
## Civilization Step 3 — territory, nations & political borders
`PlanetNation.cpp` (engine, raylib-free, **no RNG** → tectonic stream untouched). `computeTerritory()`
turns the settlement set into **realms** + per-cell ownership, all a **pure deterministic function of
the (saved) settlements** — so it is *recomputed*, never saved (no `SAVE_VERSION` bump), and step-back
replays it as populations restore. (1) **Influence range** per living settlement scales with population
(`civTerritory*`). (2) **Realm grouping**: process settlements largest→smallest; a settlement joins the
nearest larger **capital** whose annexation reach (`civVassalRange × its range`) covers it (→ a vassal
town of that kingdom) else founds its own nation; tier City-state / Kingdom / Empire by member count /
total pop. (3) **Per-cell ownership**: each land cell goes to the settlement maximising `range
angular-distance` if > 0, else **wilderness** (1) — influence-limited, with frontiers; the cell's
nation is that settlement's. Peaceful + population-driven (no conquest yet).
Render: a `Territory` colour mode (`nationColor`, golden-ratio HSV) + dark **border lines**
(`buildNationBorders` — the plate dual-contour reused, keyed on `cellNation()`) + realm labels at
capitals (key `P`), a **Realms** tab (8th), a cell-info realm line, and **kind=4** `WorldEvent`s
(realm founded / rises to an empire / collapsed — detected in `detectNationEvents` by matching nations
across a recompute by capital id). The viewer recomputes territory + rebuilds borders **once per sim
year** (`liveAdvance` year-tick), and on placement / load / step-back; `buildGeometry()` clears
`nations`/`sCellNation`/`sSettleNation` on reseed. Knobs `civTerritory*`/`civVassal*`/`civEmpire*`.
## Civilization Step 4 — culture, beliefs & governments
`PlanetCulture.cpp` (engine, raylib-free, **no RNG** → tectonic stream untouched). `computeCultures()`
runs right after `computeTerritory()` (it reads `nations` / `sCellNation`) and is a **pure deterministic
function** of the settlement set + geography + biomes — so it is *recomputed*, never saved (no
`SAVE_VERSION` bump), and step-back replays it for free. (1) **One culture per inhabited continent**:
living settlements are grouped by `Settlement.regionId` (its continent geography-feature; fall back to
the language `bank` when there's no geography). Each `Culture` gets a **people name** (`namegen::makeName`
from the continent's bank), a dominant **ethos** = argmax of environmental fractions over its settlement
cells (coastal→Seafaring, mountains/hills→Highland, desert/dry→Nomadic, fertile-biome→Agrarian; if none
≥ 0.34, a `cultHash` pick of Mercantile/Warlike/Agrarian), and a **religion** — a `Faith` focus from the
dominant biome (coast→Sea, mountains→Sky, desert→Sun, cold→Ancestors, forest/wetland→Harvest, with a
rare hash Moon/War) plus a generated `faithName`. (2) **Government per realm**: each `Nation` gets a
`GovType` from its tier + a `cultHash` pick and its `name` is **rewritten** to fold it in ("Republic of
X", "Duchy of X", "X Theocracy", "X Confederation", "X Dominion"…); `nation.cultureId` = its capital's
culture. (3) **Per-cell culture** `sCellCulture[c]` = the culture of `sCellNation[c]`'s nation (reuses
territory's ownership) → the culture view shows **cultural blocs** over the political map.
**Culture-driven grouping**: `computeTerritory()`'s realm-join test now requires the vassal and its
candidate capital to share a `regionId`, so every realm is **mono-cultural** (no kingdom spans two
continents). Render: a `Culture` colour mode (`cultureColor`) + pale `buildCultureBorders` lines + a
`Cultures` tab (9th) + a cell-info culture/faith line + government-aware realm labels, all under key `X`;
`rebuildTerritory()` computes territory **then** cultures and rebuilds both border sets each sim year /
placement / load / step-back. No config knobs (rules are constants in `computeCultures()`).
## Civilization Step 5 — conflict, war & shifting borders
`PlanetConflict.cpp` (engine, raylib-free). Unlike the derived Steps 34, war is **stateful &
path-dependent**, so it adds saved state and bumps the save to **v21**. `stepConflict(long year)` runs
**once per sim year** (the viewer calls it in the `liveAdvance` year-tick **before** `rebuildTerritory`,
only when `dtWeather > 0` so a step-back doesn't re-advance it), reads the current realms (from last
recompute) and **mutates the world**:
1. **Revolts + pruning**: a conquered city (`sSettleAllegiance[s] ≥ 0`) whose overlord capital is gone is
freed; else it revolts with a probability rising in foreign-culture + distance (contestable conquest).
2. **Prosecute wars**: each `War` (identified by attacker/defender **capital settlement index**, the
stable id) resolves realms by capital; a battle moves `warscore` by the strength ratio (strength =
`totalPop` × Warlike bonus × defender home advantage); frontier cities take casualties; past
`warConquerScore` the winner **conquers** a loser frontier city (`sSettleAllegiance` flips to the
winner's capital) or **sacks** it (population → ruins); a war ends on exhaustion / a 60-battle cap /
a belligerent's capital falling.
3. **Declare**: adjacency is by **settlement proximity** (`civTerritoryMax·1.5`, since territory is
influence-limited with wilderness marches); hostility = ambition (size gap) + ideology (culture/faith
difference) + contested frontier + a per-pair yearly hash streak; the highest-hostility unwarred pairs
declare (attacker = the stronger), capped at `warMaxConcurrent`.
All randomness comes from a separate `sWarRng` (seeded `cfg.seed ^ 0x5A7B0A11u`) + pure hashes, so
tectonics stay bit-identical and outcomes are reproducible.
`computeTerritory()` (`PlanetNation.cpp`) honours allegiance: a settlement with a living overlord is
forced into that capital's realm, **bypassing the mono-cultural rule** — so a conquered city's cells
become the victor's and **the border moves** (a chain-resolving capital lookup keeps a conquered vassal's
vassals consistent). State — `sSettleAllegiance` + `wars` + `sWarRng`/`sWarNextId` — is saved (v21, a new
`hasConflict` `readState` param + a per-frame block in the step-back history) and added to
`WeatherSnapshot` / `captureWeather`/`restoreWeather`, so `,`/`.` rewind conquests + revolts. Render: red
`buildWarFrontier` lines over the Territory view (`P`), a red at-war marker + active-wars list in the
Realms tab, a cell-info "AT WAR" flag, a HUD war count, and **kind=5** events (declare / capture / sack /
revolt / peace). Knobs `war*`.
## Civilization Step 6 — diplomacy, alliances & coalitions
Same file (`PlanetConflict.cpp`), same yearly `stepConflict` tick, same `sWarRng` — diplomacy is the war
subsystem's politics layer. A **`DiploTie`** { `a`, `b` (capital settlement indices, a<b), `attitude`
∈[1,1], `truceUntil`, `DiploKind` } per interacting realm pair; the vector `diplomacy` is **stateful**,
saved (**v22**) + snapshotted (a per-frame block in the step-back history, like the v21 war block). New
yearly order inside `stepConflict`: **revolts → prosecute → diplomacy → declare**.
- **Prosecute → peace treaty**: when a war ends by exhaustion, the pair's tie gets a
`truceUntil = year + diploTruceYears` and an attitude drop of `diploWarGrudge` (a scar).
- **Diplomacy pass**: prune ties whose endpoint settlement died; for each near realm pair (the
settlement-proximity adjacency, now built once at the top and shared with war-declare) drift `attitude`
toward a target set by culture+faith affinity (`diploAffinity`) plus a per-pair personality noise, minus
a war penalty, plus truce recovery; reclassify into Alliance / Non-aggression / Rival by threshold
(`diploAllyThreshold`/`diploNonAggThreshold`/`diploRivalThreshold`, with a ±0.08 hysteresis) and emit a
**kind-6** event on a kind change.
- **Declare**: skip allied / non-aggression / under-truce pairs; hostility's ideology term is now
`max(0, attitude)` (rivals fight); after A declares on B, every **Alliance** tie of B makes that ally
declare its own war on A (a **coalition**, reusing the 1-v-1 war path), capped by `warMaxConcurrent`.
Query helpers `diploBetween(nationA,nationB)` / `realmsAllied(...)` map nation index → capital → tie.
Render: `buildDiploLinks` (great-circle **arcs** between allied/rival capitals — green/dark-red) over the
Territory view (`P`); the Realms tab shows per-realm ally/rival counts + the active-wars list; cell-info
an allies/rivals line; kind-6 events (`=` icon). `captureWeather`/`restoreWeather` carry `diplomacy`.
Knobs `diplo*`.
## Civilization Step 7 — trade & economy
`PlanetTrade.cpp` (engine, raylib-free, **no RNG**). `computeTrade()` is a **pure derived function** of the
settlement set + geography + wars/diplomacy — recomputed each sim year in `rebuildTerritory` (after
`computeTerritory`/`computeCultures`), **not saved** (no `SAVE_VERSION` bump), rewinds for free. Per living
settlement it flags **coastal** (a neighbour ≤ seaLevel) and **river** (`sDischarge` > `riverThreshold`);
then for each settlement pair within an effective reach (`tradeLandRange` + `tradeRiverBonus` if either is
on a river + `tradeSeaRange` if both are coastal) it adds a **`TradeLink`** whose **kind** is Sea/River/
Land and whose **volume** = f(both populations, proximity, a per-kind bonus) × a political factor
(`tradeWarBlock` if the realms are at war, `tradeAllyBonus` if allied — via `realmsAtWar`/`realmsAllied`).
**Prosperity** `sProsperity[s]` = a small base + Σ its link volumes (a route hub → rich), clamped; the
**wealth field** `sCellWealth[c]` = the prosperity of the cell's owning settlement (`sCellSettleOwner`, now
filled by `computeTerritory`), so the heat map matches territory.
Prosperity feeds `stepCivilization` (`K ·= 1 + tradeProsperityWeight·prosperity`, PlanetCiv.cpp) so hubs
grow into bigger cities; it's refreshed yearly and read each frame. Two light feedbacks in `stepConflict`
read **last year's** economy (one-year lag, deterministic): trade-partner realm pairs get a `tradeDiploBonus`
attitude nudge, and a wealthy weak neighbour adds a `tradeTemptWar` war-hostility term. Render: a `Wealth`
colour mode (`wealthColor`, normalised to the current max) + `buildTradeRoutes` arcs (sea cyan / land+river
amber) under key `Z`, a gold wealth dot per settlement in the Civ tab, a cell-info prosperity/route line.
Knobs `trade*`.
## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run:
```
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \
src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
src/sim/PlanetVolcano.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp src/sim/PlanetGeography.cpp \
src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp src/sim/PlanetNation.cpp \
src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp src/sim/PlanetTrade.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography / test_ecoregions /
# test_civ / test_nation / test_culture / test_conflict / test_diplomacy / test_trade use the same source list.
```
(add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are
data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).