diff --git a/README.md b/README.md index b06035f..bdb0edc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Hex-based procedural world generator for TTRPGs, worldbuilding, and wargaming. - **Reproducible:** any world can be regenerated from a single integer seed - **Pipelined:** each generation stage is a pure transformer — swap or extend without touching others +For a full reference of every calculation, formula, and config value, see +[docs/REFERENCE.md](docs/REFERENCE.md). + ## Status | Phase | Description | Status | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md new file mode 100644 index 0000000..9109fe7 --- /dev/null +++ b/docs/REFERENCE.md @@ -0,0 +1,1387 @@ +# Worldgen Reference + +How every calculation works, and what every value does. The code is the +ground truth — every non-trivial formula and number cited here links to its +source line. + +This doc is split for two audiences: + +- **§3 Pipeline & Algorithms** — for extending or debugging stages. Each + stage is described with reads/writes, the algorithm in plain English, and + every key formula tagged with `file:line`. +- **§4 Configuration Reference** — for tuning a world without reading code. + Tables of `WorldConfig` parameters with defaults, ranges, and effects. + +§5 catalogues the magic numbers that sit *outside* `WorldConfig` but still +shape every map. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Data Model](#2-data-model) +3. [Pipeline & Algorithms](#3-pipeline--algorithms) + - [3.1 Elevation](#31-elevation) + - [3.2 Erosion](#32-erosion) + - [3.3 Terrain Classification](#33-terrain-classification) + - [3.4 Water Bodies](#34-water-bodies) + - [3.5 Hydrology](#35-hydrology) + - [3.6 Climate](#36-climate) + - [3.7 Biomes](#37-biomes) + - [3.8 Land Cover](#38-land-cover) + - [3.9 Habitability](#39-habitability) + - [3.10 City & Town Placement](#310-city--town-placement) + - [3.11 Interurban Roads](#311-interurban-roads) + - [3.12 Cultivation (Cities & Towns)](#312-cultivation-cities--towns) + - [3.13 Village Placement](#313-village-placement) + - [3.14 Village Tracks](#314-village-tracks) + - [3.15 Village Cultivation](#315-village-cultivation) +4. [Configuration Reference](#4-configuration-reference) +5. [In-Code Constants](#5-in-code-constants) +6. [Outputs](#6-outputs) +7. [Glossary](#7-glossary) + +--- + +## 1. Overview + +### Scale + +- **1 hex = 1 km.** Hex size and elevation span can be tuned via + `hex_size_m` and `road_elev_range_m` (used for grade-percent math in + road costs), but the pipeline assumes the kilometre-scale interpretation + for things like settlement separation and cultivation radii. +- **Default grid:** 128 × 128 (≈16,000 km², the size of a small kingdom). +- **Coordinates:** axial `(q, r)`, flat-top hexagons. + Neighbours, distance, ranges, and pixel conversion live in + [worldgen/core/hex_grid.py](../worldgen/core/hex_grid.py). + Hex distance uses the standard axial/cube hex-distance formula: + `(|Δq| + |Δr| + |Δq+Δr|) // 2` ([hex_grid.py:26](../worldgen/core/hex_grid.py#L26)). + +### Reproducibility + +A single integer seed reproduces every world bit-for-bit. Mechanism: + +1. The pipeline holds a parent `numpy.random.Generator` seeded from the CLI + `--seed` ([pipeline.py:31](../worldgen/core/pipeline.py#L31)). +2. Before each stage runs, the parent generator draws a fresh 32-bit integer + and seeds a **child** `Generator` for that stage + ([pipeline.py:50](../worldgen/core/pipeline.py#L50)). +3. Stages only ever use their child RNG — never global `numpy.random` or + Python's `random`. Container iteration (`set`, `dict.items()`) is + sorted before any random choice that depends on order, so insertion-order + nondeterminism cannot leak into output. + +The seed and full config are written into `WorldState.metadata` at the +start of the run ([pipeline.py:46–47](../worldgen/core/pipeline.py#L46)) +and round-trip through `world.json`. + +### Pipeline + +15 stages, run in this exact order +([cli.py:58–74](../worldgen/cli.py#L58)). Each stage is a pure +transformer: `state → state` +([pipeline.py:20](../worldgen/core/pipeline.py#L20)). Stages never write +files; that's `worldgen/export/`'s job. + +```mermaid +flowchart TD + Start([seed + WorldConfig]) --> Elev + + subgraph Terrain["Terrain & Hydrology"] + Elev[ElevationStage
noise → hex.elevation] + Eros[ErosionStage
carves channels in hex.elevation] + Tcls[TerrainClassificationStage
hex.terrain_class] + Wbod[WaterBodiesStage
splits OCEAN vs LAKE; fixes COAST] + Hydr[HydrologyStage
state.rivers, hex.river_flow, river tags] + Elev --> Eros --> Tcls --> Wbod --> Hydr + end + + subgraph Climate["Climate & Cover"] + Clim[ClimateStage
hex.temperature, hex.moisture] + Biom[BiomeStage
hex.biome + WETLAND override] + Lcov[LandCoverStage
hex.land_cover] + Clim --> Biom --> Lcov + end + + subgraph Settle["Settlements & Roads"] + Hab[HabitabilityStage
hex.habitability ∈ [0,1]] + Cit[CityTownStage
cities + towns; pass tags] + Iur[InterurbanRoadStage
PRIMARY/SECONDARY + habitability +0.2] + Cul[CultivationStage
hex.cultivated near cities/towns] + Vil[VillagePlacementStage
villages on frontier / near roads] + Vtk[VillageTrackStage
TRACK roads to network] + Vcul[VillageCultivationStage
hex.cultivated near villages] + Hab --> Cit --> Iur --> Cul --> Vil --> Vtk --> Vcul + end + + Hydr --> Clim + Lcov --> Hab + Vcul --> End([WorldState]) + + classDef terrain fill:#e8d5b7,stroke:#8b6f47,color:#3a2e1c + classDef climate fill:#cfe8d4,stroke:#5a8a6f,color:#1c3a2e + classDef settle fill:#d5d8e8,stroke:#5a6f8a,color:#1c2a3a + classDef io fill:#f5f5f5,stroke:#666,color:#222 + + class Elev,Eros,Tcls,Wbod,Hydr terrain + class Clim,Biom,Lcov climate + class Hab,Cit,Iur,Cul,Vil,Vtk,Vcul settle + class Start,End io +``` + +--- + +## 2. Data Model + +### `WorldState` — [worldgen/core/world_state.py:25–34](../worldgen/core/world_state.py#L25) + +| Field | Type | Notes | +|---|---|---| +| `seed` | `int` | The RNG seed for this run | +| `width`, `height` | `int` | Grid dimensions in hexes | +| `hexes` | `dict[HexCoord, Hex]` | Every cell, keyed by `(q, r)` | +| `rivers` | `list[River]` | Source-to-confluence (or source-to-sea) paths | +| `settlements` | `list[Settlement]` | Cities, towns, and villages combined | +| `roads` | `list[Road]` | PRIMARY / SECONDARY / TRACK paths | +| `metadata` | `dict` | `{"seed": ..., "config": ...}` snapshot | + +Convenience accessors: `all_land()`, `all_ocean()`, `all_lakes()`, +`all_water()` ([world_state.py:49–79](../worldgen/core/world_state.py#L49)). + +### `Hex` — [worldgen/core/hex.py:66–80](../worldgen/core/hex.py#L66) + +| Field | Type | Range | Written by | +|---|---|---|---| +| `coord` | `HexCoord` (`(q, r)`) | — | construction | +| `elevation` | `float` | `[0.0, 1.0]` after normalization | Elevation, Erosion, Hydrology (lake fill) | +| `moisture` | `float` | `[0.0, 1.0]` | Climate | +| `temperature` | `float` | `[0.0, 1.0]` (clamped) | Climate | +| `terrain_class` | `TerrainClass` | enum | Terrain Class, Water Bodies, Hydrology | +| `biome` | `Biome \| None` | enum | Biome | +| `land_cover` | `LandCover \| None` | enum | Land Cover | +| `river_flow` | `float` | `[0.0, 1.0]`, normalized to map max | Hydrology | +| `habitability` | `float` | `[0.0, 1.0]` | Habitability, Roads (+0.2 near roads) | +| `settlement` | `Settlement \| None` | — | City/Town, Village | +| `road_connections` | `set[HexCoord]` | adjacent cells with roads | Interurban Roads, Village Tracks | +| `cultivated` | `bool` | — | Cultivation, Village Cultivation | +| `tags` | `set[str]` | — | many stages; vocabulary below | + +### Enums + +- **`TerrainClass`** — `OCEAN, LAKE, COAST, FLAT, HILL, MOUNTAIN` + ([hex.py:7–13](../worldgen/core/hex.py#L7)). +- **`Biome`** — `TUNDRA, BOREAL, TEMPERATE_FOREST, GRASSLAND, SHRUBLAND, + DESERT, TROPICAL, WETLAND, OCEAN, ALPINE` + ([hex.py:30–40](../worldgen/core/hex.py#L30)). +- **`LandCover`** — `OPEN_WATER, BOG, MARSH, DENSE_FOREST, WOODLAND, SCRUB, + OPEN, TUNDRA, DESERT, ALPINE, BARE_ROCK` + ([hex.py:16–27](../worldgen/core/hex.py#L16)). +- **`SettlementTier`** — `CITY, TOWN, VILLAGE` + ([hex.py:52–55](../worldgen/core/hex.py#L52)). +- **`SettlementRole`** — `AGRICULTURAL, PORT, MINING, FORTRESS, MARKET` + ([hex.py:58–63](../worldgen/core/hex.py#L58)). +- **`RoadTier`** — `PRIMARY, SECONDARY, TRACK` + ([world_state.py:7–10](../worldgen/core/world_state.py#L7)). + +### Tags Vocabulary (`Hex.tags`) + +| Tag | Meaning | Set by | +|---|---|---| +| `"river"` | Hex carries a river path | Hydrology | +| `"headwater"` | River hex with no upstream river neighbour | Hydrology `_tag_hexes` | +| `"confluence"` | River hex with ≥2 upstream river neighbours | Hydrology | +| `"river_mouth"` | River hex on map border or adjacent to ocean/lake | Hydrology | +| `"ford"` | First road crossing of a river hex | `tag_river_crossings` | +| `"bridge"` | Second road to cross the same river hex (upgrades a ford) | `tag_river_crossings` | +| `"pass"` | HILL hex that's a local-max habitability within 3-hex range, no settlement | City/Town | +| `"confluence_town"` | TOWN settled on a hex already tagged `"confluence"` | City/Town | + +--- + +## 3. Pipeline & Algorithms + +### 3.1 Elevation + +[stages/elevation.py](../worldgen/stages/elevation.py) + +**Purpose:** Generate the base heightmap from layered noise. + +**Reads:** nothing (works from an empty `WorldState`). +**Writes:** `hex.elevation` for every hex. + +**Config:** `noise_octaves`, `noise_persistence`, `noise_lacunarity`, +`noise_scale`, `domain_warp_strength`, `continent_falloff`, +`elevation_gradient`. + +**Algorithm** + +1. Two independent OpenSimplex generators are seeded from the stage's RNG — + one for the base height field, one for **domain warping** + ([elevation.py:13–16](../worldgen/stages/elevation.py#L13)). +2. Each grid coordinate is offset by the warp generator before sampling + the base field. This breaks up the visible "noise grain" and produces + more organic coastlines: + ``` + warp_x = warp.noise(q, r) * domain_warp_strength + warp_y = warp.noise(q+100, r+100) * domain_warp_strength + nx = q + warp_x; ny = r + warp_y + ``` + ([elevation.py:23–28](../worldgen/stages/elevation.py#L23)). The + `+100` offset on the y warp ensures the two channels are independent + samples of the same generator. +3. **Fractal Brownian motion (fBm)** sums `noise_octaves` octaves of the + base noise: + ``` + for j in range(octaves): + v += noise(nx * lacunarity^j, ny * lacunarity^j) * persistence^j + elevation = v / sum(persistence^j) # normalize to [-1, 1] + ``` + ([elevation.py:31–43](../worldgen/stages/elevation.py#L31)). Higher + `persistence` keeps more detail in late octaves (rougher); higher + `lacunarity` increases frequency between octaves (more high-frequency + detail). +4. **Linear gradient** (optional) tilts the map by `(gx, gy)` using + coordinates centred at `[-0.5, +0.5]` + ([elevation.py:46–50](../worldgen/stages/elevation.py#L46)). Useful + for biasing one edge upward (e.g., a continental ridge). +5. **Continent falloff** (optional) multiplies by + `max(0, 1 - sqrt(qf² + rf²))` where `qf`, `rf` are normalised to + `[-1, +1]` — a circular cone that drops to 0 at the corners + ([elevation.py:52–56](../worldgen/stages/elevation.py#L52)). This + is what gives default worlds their island-on-ocean look. +6. **Linear stretch** to `[0, 1]` + ([elevation.py:58–60](../worldgen/stages/elevation.py#L58)). + +**Gotchas** + +- The output range is always `[0, 1]` *after* stretch, but + `sea_level=0.45` is then used as a fixed cutoff. So elevation values + are not absolute — `0.45` means "the 45th percentile of the noise + distribution after falloff," not "450 m." +- Without `continent_falloff`, expect a nearly-full-coverage land map + unless `sea_level` is raised. + +--- + +### 3.2 Erosion + +[stages/erosion.py](../worldgen/stages/erosion.py) + +**Purpose:** Sculpt valleys by simulating water particles flowing downhill, +removing high-frequency noise, and producing natural-looking channels. + +**Reads:** `hex.elevation` (and `sea_level` to bound the simulation to +land). +**Writes:** `hex.elevation` (modified, then re-normalised to `[0, 1]`). + +**Config:** `erosion_iterations`, `erosion_inertia`, `erosion_capacity`, +`erosion_deposition`, `erosion_erosion_rate`, +`erosion_channel_affinity_gain`, `erosion_affinity_update_interval`, +`sea_level`. + +**Algorithm** (particle-based hydraulic erosion, JIT-compiled with numba +when available — falls back to pure Python). + +For each of `erosion_iterations` particles, drop one at a randomly chosen +land hex and simulate up to `_MAX_STEPS = 64` steps of flow +([erosion.py:18, 42](../worldgen/stages/erosion.py#L18)): + +1. **Compute local gradient** from 4 neighbours (clamped at edges): + ``` + gx = (right - left) * 0.5 + gy = (down - up) * 0.5 + ``` + ([erosion.py:52–57](../worldgen/stages/erosion.py#L52)). +2. **Update direction** with momentum: + ``` + dir = inertia * dir_prev - (1 - inertia) * gradient + ``` + ([erosion.py:59–60](../worldgen/stages/erosion.py#L59)). `inertia` near + 0 = pure gradient descent; near 1 = particle ignores terrain. Default + `0.05` keeps channels mostly aligned with steepest descent but + smooths sharp turns. +3. **Move one cell** along the normalised direction + ([erosion.py:65–69](../worldgen/stages/erosion.py#L65)). +4. **Sediment transport**: + ``` + dh = elev[next] - elev[here] # negative = downhill + capacity = max(-dh, 0.01) * speed * water * erosion_capacity + if sediment > capacity: + deposit = erosion_deposition * (sediment - capacity) + arr[here] += deposit; sediment -= deposit + else: + erode = min(erosion_erosion_rate * (capacity - sediment), |dh| if dh<0 else 0) + arr[here] -= erode; sediment += erode + channel_affinity[here] += erosion_channel_affinity_gain + ``` + ([erosion.py:75–87](../worldgen/stages/erosion.py#L75)). + The `0.01` floor on `-dh` prevents capacity from collapsing to 0 on + flats, which would freeze sediment in place. +5. **Update speed/water** between steps: + ``` + speed = max(speed + dh, 0.01) + water *= 0.99 # _EVAPORATION + ``` + ([erosion.py:89–90](../worldgen/stages/erosion.py#L89)). Particles + accelerate downhill, decelerate uphill, and gradually evaporate so + they cannot dig forever. +6. **Termination** when the particle leaves the grid, drops below sea + level (deposits remaining sediment), or stalls below `1e-8` direction + magnitude + ([erosion.py:42–66](../worldgen/stages/erosion.py#L42)). + +**Channel affinity** — a self-reinforcing trick. Every +`erosion_affinity_update_interval` particles, the spawn distribution is +re-weighted by `channel_affinity` so later particles tend to start in +already-eroded channels, deepening them +([erosion.py:136–143](../worldgen/stages/erosion.py#L136)). With the +default `affinity_update_interval=500`, the first 500 particles spawn +uniformly to discover channels, then later batches reinforce them. + +**Post-process** + +1. Gaussian blur with `sigma=0.5` to remove single-cell artefacts + ([erosion.py:145](../worldgen/stages/erosion.py#L145)). +2. Linear stretch to `[0, 1]` + ([erosion.py:147–149](../worldgen/stages/erosion.py#L147)). + +**Gotchas** + +- Erosion is the bottleneck of the pipeline at default settings (15,000 + particles × up to 64 steps). Halving `erosion_iterations` ≈ halves + total runtime with mostly cosmetic loss of channel detail. +- Without numba, this stage is roughly 10× slower; install numba + (`pip install numba`) for full speed. + +--- + +### 3.3 Terrain Classification + +[stages/terrain_class.py](../worldgen/stages/terrain_class.py) + +**Purpose:** Bucket every hex into `OCEAN / COAST / FLAT / HILL / MOUNTAIN` +based on absolute elevation and local steepness. + +**Reads:** `hex.elevation`, neighbours. +**Writes:** `hex.terrain_class`. + +**Config:** `sea_level`, `terrain_hill_gradient`, +`terrain_mountain_gradient`. + +**Algorithm** ([terrain_class.py:8–41](../worldgen/stages/terrain_class.py#L8)): + +``` +coast_threshold = sea_level + 0.05 # hardcoded offset + +# Pass 1 +for hex in all hexes: + if hex.elevation < sea_level: + hex.terrain_class = OCEAN + +# Pass 2 +for hex in non-ocean hexes: + if hex.elevation < coast_threshold and any neighbour is OCEAN: + hex.terrain_class = COAST + continue + + gradient = mean(|hex.elev - n.elev| for n in neighbours) + if gradient > terrain_mountain_gradient or hex.elevation > 0.8: + hex.terrain_class = MOUNTAIN + elif gradient >= terrain_hill_gradient: + hex.terrain_class = HILL + else: + hex.terrain_class = FLAT +``` + +**Notes** + +- The `+0.05` coast offset and the hardcoded `> 0.8` mountain ceiling are + not in `WorldConfig` — see §5. +- Inland water created by Erosion lows but never connecting to a map + edge is classified OCEAN here; the next stage corrects that. + +--- + +### 3.4 Water Bodies + +[stages/water_bodies.py](../worldgen/stages/water_bodies.py) + +**Purpose:** Distinguish OCEAN (map-edge-connected) from inland LAKE, and +fix COAST hexes that ended up adjacent only to a lake. + +**Reads:** `hex.terrain_class` (OCEAN from previous stage). +**Writes:** `hex.terrain_class` (some OCEAN → LAKE; some COAST → +`HILL/FLAT/MOUNTAIN`). + +**Algorithm** ([water_bodies.py:21–39](../worldgen/stages/water_bodies.py#L21)): + +1. Collect every hex marked OCEAN. BFS over OCEAN-OCEAN adjacency to + discover connected components ([water_bodies.py:42–52](../worldgen/stages/water_bodies.py#L42)). +2. For each component: if any hex sits on the map border, leave it as + OCEAN. Otherwise, reclassify every hex in the component to LAKE + ([water_bodies.py:33–36](../worldgen/stages/water_bodies.py#L33)). +3. **`_fix_coast_hexes`** ([water_bodies.py:60–101](../worldgen/stages/water_bodies.py#L60)): + COAST was assigned earlier based on adjacency to OCEAN, but some of + those neighbours are now LAKE. For each COAST hex that has no actual + ocean neighbour: + - If it sits beside a lake at low elevation, *keep* COAST (acts as a + lake shore for downstream stages). + - Otherwise re-run the gradient classification (`HILL/FLAT/MOUNTAIN`). + + This pass uses `sea_level`, `terrain_hill_gradient`, and + `terrain_mountain_gradient` from `state.metadata["config"]` — that's + why `pipeline.run()` snapshots config into metadata at startup. + +--- + +### 3.5 Hydrology + +[stages/hydrology.py](../worldgen/stages/hydrology.py) + +The biggest stage in the pipeline (~780 lines). It builds the river +network from the eroded heightmap. + +**Reads:** `hex.elevation`, `hex.terrain_class`. +**Writes:** `hex.river_flow`, `hex.tags` (river/headwater/confluence/ +river_mouth), `state.rivers`. May also raise lake water-levels and +convert land hexes to LAKE/OCEAN if a basin needs to expand to its +spillway. + +**Config:** `river_flow_threshold`, `river_flow_continuous`. + +**Algorithm** + +Nine steps, top to bottom in +[hydrology.py:11–124](../worldgen/stages/hydrology.py#L11): + +1. **Priority-Flood** sink-fills closed depressions on land, using a + min-heap seeded with ocean and border land hexes (Barnes et al. 2014, + [hydrology.py:153–186](../worldgen/stages/hydrology.py#L153)). After + this pass, every land hex has a non-decreasing path of `filled[...]` + values to the sea. + +2. **Epsilon tilt** adds tiny perturbations to break ties on plateaus + ([hydrology.py:35–40](../worldgen/stages/hydrology.py#L35)): + ``` + filled[c] += 1e-6 * dist_from_water[c]/max_dist + + 1e-6 * 1e-4 * (q + r) / (w + h) + ``` + The first term gives plateaus a gradient *away from* water (so they + drain consistently); the second is a coordinate-based tiebreaker that + makes the result reproducible regardless of dict iteration order. + +3. **Flow direction** — for each land hex, point at the lowest neighbour + on the filled surface ([hydrology.py:188–235](../worldgen/stages/hydrology.py#L188)). + Two subtleties: + - For ocean/lake neighbours, use **raw** elevation, not filled, so a + priority-flood-raised lake never appears higher than the actual + terrain around it. + - If both `from` and `to` sit on the map border, terminate flow at + `from` rather than letting the river creep along the edge. + +4. **Flow accumulation** — Kahn's topological sort, then accumulate + ([hydrology.py:237–265](../worldgen/stages/hydrology.py#L237)): + ``` + acc[c] = 1 + sum(acc[upstream]) + ``` + Each hex contributes 1 unit; downstream hexes accumulate the sum of + their upstream tributaries. Map maximum is the largest river-mouth + value. + +5. **River extraction** ([hydrology.py:51–60](../worldgen/stages/hydrology.py#L51)): + ``` + n_river = max(1, round(n_land * river_flow_threshold)) + river_set = top-n_river hexes by acc + ``` + Sorting + slicing (rather than a quantile threshold) avoids over- + selection at tie boundaries. With default `river_flow_threshold=0.05`, + the top 5 % of land hexes by drainage become river hexes. + +6. **Build River objects** — for each headwater (river hex with no + upstream river hex), trace `flow_dir` to its mouth + ([hydrology.py:296–394](../worldgen/stages/hydrology.py#L296)). If a + trace stalls before reaching water, three fallbacks try in order: + - **Stage 1:** elevation-guided Dijkstra avoiding already-traced hexes. + Uphill cost = `1.0 + 1000 * Δelev` + ([hydrology.py:435–436](../worldgen/stages/hydrology.py#L435)) so the + path stays in valleys. + - **Stage 2:** same Dijkstra without the avoid set. + - **Stage 3:** plain BFS (always succeeds on a finite grid). + + Fallback hexes are inserted into `river_set` and `flow_dir` is updated + so subsequent tagging stays consistent. + +7. **Tagging** ([hydrology.py:267–294](../worldgen/stages/hydrology.py#L267)): + - `headwater`: river hex with 0 upstream river neighbours. + - `confluence`: river hex with ≥2 upstream river neighbours. + - `river_mouth`: river hex on the map border, or with an ocean/lake + neighbour. + +8. **Lake drainage** ([hydrology.py:481–711](../worldgen/stages/hydrology.py#L481)) + guarantees every lake has a visible outflow river: + - Find each lake's natural **spillway** (lowest land hex on its + perimeter, by raw elevation). + - Raise the lake's surface to that elevation; flood-fill any land + below the new water-level, converting it to LAKE. + - If the expanded lake reaches the map edge, promote the whole + component to OCEAN (it has touched the sea). + - Otherwise, run elevation-guided Dijkstra from the spillway toward + the nearest border or ocean neighbour. Append the result as a new + `River` and merge it with any existing river network on the way out. + +9. **Confluence splitting** ([hydrology.py:714–758](../worldgen/stages/hydrology.py#L714)): + the source-to-sea paths produced in step 6 overlap whenever + tributaries merge. Rivers are sorted by descending `flow_volume` and + each one claims its hexes; later (lower-flow) rivers are trimmed at + the first already-claimed hex. The result: every `River` object in + `state.rivers` is a single source-to-confluence (or source-to-sea) + segment with no duplicate trunk drawing. + +**Output** + +- If `river_flow_continuous=False` (default): `hex.river_flow = acc[c] / max_acc` + for every river hex, and `0.0` for everything else. +- If `True`: every draining land hex gets a normalised flow value (handy + if you want to render the underlying drainage gradient). + +--- + +### 3.6 Climate + +[stages/climate.py](../worldgen/stages/climate.py) + +**Purpose:** Compute temperature and moisture fields. Two independent +sub-passes, run sequentially. + +**Reads:** `hex.elevation`, `hex.terrain_class`, `hex.tags` (`"river"`), +`hex.river_flow`. +**Writes:** `hex.temperature`, `hex.moisture`. + +**Config:** `base_temperature`, `latitude_temp_range`, +`altitude_lapse_rate`, `wind_direction`, `orographic_strength`, +`base_moisture`, `moisture_bleed_passes`, `moisture_bleed_strength`, +`sea_level`. + +#### Temperature ([climate.py:18–39](../worldgen/stages/climate.py#L18)) + +For each hex: +``` +row_frac = r / max(height - 1, 1) # 0 at top, 1 at bottom +lat_temp = sin(row_frac * π) # 0 at poles, 1 at equator +temperature = base_temperature + + (lat_temp - 2/π) * latitude_temp_range + - elevation * altitude_lapse_rate +temperature = clamp(temperature, 0, 1) +``` + +The `2/π` subtraction is the mean of `sin` over `[0, π]`, so +`base_temperature` represents the *true map-average* temperature instead +of the equator value. The output is then Gaussian-blurred with +`sigma=1.0` to smooth pixel-by-pixel jaggedness +([climate.py:36](../worldgen/stages/climate.py#L36)). + +**At default settings** (`latitude_temp_range=0.1`, `altitude_lapse_rate=0.4`): +elevation dominates latitude. A peak at elevation `0.8` is `0.32` colder +than sea level — equivalent to crossing more than the entire pole-to- +equator gradient. This was tuned for `1 hex = 1 km`; a continent-scale +map (1 hex = 100 km) would need a much larger `latitude_temp_range`. + +#### Moisture ([climate.py:41–161](../worldgen/stages/climate.py#L41)) + +Three sub-steps: + +1. **Orographic precipitation.** Sort all hexes by their dot product with + the wind direction (so upwind hexes process first). The wind carries + atmospheric moisture; lifting it over higher terrain causes it to + condense as rain ([climate.py:52–93](../worldgen/stages/climate.py#L52)): + ``` + incoming = mean(atm[upwind neighbours]) or 1.0 if none upwind + lift = max(0, hex.elevation - sea_level) + fraction = min(1, lift * orographic_strength) + precip = incoming * fraction + hex.moisture = precip + atm[hex] = incoming - precip # depleted moving downwind + ``` + Result: windward slopes are wet, lee slopes (rain shadows) dry. + +2. **River + coastal bonuses** ([climate.py:96–108](../worldgen/stages/climate.py#L96)). + For every land hex: + - If `moisture_bleed_passes == 0` (default), add `+0.15` if any + neighbour has `"river"` tag. + - Add `+0.1` if any neighbour is OCEAN or LAKE. + + These are cumulative — a coastal river-adjacent hex gets `+0.25`. + +3. **Normalisation** to land-only `[0, 1]` ([climate.py:110–118](../worldgen/stages/climate.py#L110)). + +4. **Optional moisture bleed** ([climate.py:120–154](../worldgen/stages/climate.py#L120)). + When `moisture_bleed_passes > 0`, the flat `+0.15` river bonus is + replaced by an iterative diffusion: each pass, a hex gains + `moisture_bleed_strength * max(neighbour.river_flow)` from any + river-tagged neighbour at `≥` its own elevation (recipient lower-or-equal + to the river hex). This builds a wider + moisture corridor along big rivers, especially in valleys, but never + uphill. Re-normalised after the passes. + +5. **Base moisture floor** ([climate.py:156–160](../worldgen/stages/climate.py#L156)): + add `base_moisture` to every land hex (then clamp to `[0, 1]`). Use + to dial the whole world wetter or drier. + +**Ocean and lake hexes** keep `moisture = 1.0` +([climate.py:60–74](../worldgen/stages/climate.py#L60)). + +--- + +### 3.7 Biomes + +[stages/biomes.py](../worldgen/stages/biomes.py) + +**Purpose:** Whittaker-style biome assignment based on temperature, +moisture, elevation, and water/river adjacency. + +**Reads:** `hex.terrain_class`, `hex.elevation`, `hex.temperature`, +`hex.moisture`, `hex.tags`. +**Writes:** `hex.biome`. + +**Config:** `biome_alpine_elev`, `biome_cold_temp`, `biome_warm_temp`, +`biome_dry_moist`, `biome_wet_moist`. + +**Algorithm** ([biomes.py:7–46](../worldgen/stages/biomes.py#L7)): + +``` +if terrain_class in (OCEAN, LAKE): + biome = OCEAN +elif elevation > alpine_elev: # default 0.85 + biome = ALPINE +elif temperature < cold_temp: # default 0.25 + biome = TUNDRA if moisture < dry_moist # default 0.20 + BOREAL otherwise +elif temperature >= warm_temp: # default 0.60 + biome = DESERT if moisture < dry_moist + GRASSLAND if dry_moist <= moisture < wet_moist # default 0.50 + TROPICAL if moisture >= wet_moist +else: # temperate + biome = SHRUBLAND if moisture < dry_moist + GRASSLAND if dry_moist <= moisture < wet_moist + TEMPERATE_FOREST if moisture >= wet_moist + +# WETLAND override (post-pass) +if terrain_class in (FLAT, COAST) + and moisture > wet_moist + and "river" in tags + and elevation <= alpine_elev: + biome = WETLAND +``` + +**Notes** + +- Thresholds are in normalised `[0, 1]` units, matched to + `temperature` and `moisture`. +- The WETLAND override depends on the `"river"` tag, so it only fires + *immediately* on river hexes — not a thick wetland buffer. To get + wider wetlands, raise `moisture_bleed_passes` so more hexes pass the + `moisture > wet_moist` test. + +--- + +### 3.8 Land Cover + +[stages/land_cover.py](../worldgen/stages/land_cover.py) + +**Purpose:** Pure derivation of `land_cover` from `terrain_class`, `biome`, +and `moisture`. Adds visual texture without rolling new dice. + +**Reads:** `hex.terrain_class`, `hex.biome`, `hex.moisture`. +**Writes:** `hex.land_cover`. + +**Config:** `biome_wet_moist` (used to set the dense-forest threshold). + +**Algorithm** ([land_cover.py:16–44](../worldgen/stages/land_cover.py#L16)): + +``` +if terrain_class in (OCEAN, LAKE): OPEN_WATER +if terrain_class == MOUNTAIN: BARE_ROCK +if biome == ALPINE: ALPINE +if biome == TUNDRA: TUNDRA +if biome == DESERT: DESERT +if biome == WETLAND: + if terrain_class == COAST: MARSH + else (FLAT): BOG +if biome == BOREAL: DENSE_FOREST +if biome == TEMPERATE_FOREST and moisture > (wet_moist + 1) / 2: + DENSE_FOREST +if biome in (TEMPERATE_FOREST, TROPICAL): + WOODLAND +if biome == SHRUBLAND: SCRUB +otherwise (GRASSLAND): OPEN +``` + +**Why `(wet_moist + 1) / 2`?** Every TEMPERATE_FOREST hex already passes +`moisture >= wet_moist`, so the dense-forest threshold needs to be +higher than that to ensure both DENSE_FOREST and WOODLAND actually +appear. Splitting the surviving range in half — i.e. `(wet_moist + 1) / 2` +— gives a roughly even partition. + +--- + +### 3.9 Habitability + +[stages/habitability.py](../worldgen/stages/habitability.py) + +**Purpose:** Composite `[0, 1]` score per hex, used as the input to all +settlement placement. + +**Reads:** `hex.terrain_class`, `hex.biome`, `hex.tags`, neighbours. +**Writes:** `hex.habitability`. + +**Config:** none — all weights are hardcoded (see §5). + +**Algorithm** ([habitability.py:20–69](../worldgen/stages/habitability.py#L20)): + +``` +# Hard zeros +if terrain_class in (OCEAN, LAKE, MOUNTAIN) or biome == WETLAND: + raw = 0.0 + continue + +raw = 0.0 +if "river" in hex.tags or any neighbour has "river": + raw += 0.35 # river adjacency +raw += 0.25 * AGRI_SCORE[biome] # agricultural potential +if terrain_class == HILL and any neighbour is FLAT: + raw += 0.15 # defensible hill +if terrain_class == COAST or any neighbour is COAST: + raw += 0.15 # coastal access +if "confluence" in hex.tags: + raw += 0.10 # river junction + +# Final normalisation +hex.habitability = raw / max(raw across map) +``` + +`AGRI_SCORE` ([habitability.py:6–17](../worldgen/stages/habitability.py#L6)): +GRASSLAND `1.0`, TROPICAL `0.8`, TEMPERATE_FOREST `0.7`, SHRUBLAND `0.5`, +BOREAL `0.3`, everything else `0.0`. + +**Notes** + +- The score *before* normalisation can exceed 1 (max sum is + `0.35 + 0.25 + 0.15 + 0.15 + 0.10 = 1.00` if every bonus fires *and* + the biome is GRASSLAND), so normalisation is by the actual map-max, + not a fixed cap. +- Roads add `+0.2` to neighbour habitability *after* this stage, in + Interurban Roads — so village placement sees a different (boosted) + habitability than city/town placement did. + +--- + +### 3.10 City & Town Placement + +[stages/city_town.py](../worldgen/stages/city_town.py) + +**Purpose:** Place up to `target_city_count` cities and `target_town_count` +towns by greedy selection on habitability with minimum-separation +constraints. + +**Reads:** `hex.habitability`, `hex.terrain_class`, `hex.biome`, +`hex.elevation`, neighbours. +**Writes:** `hex.settlement`, `state.settlements`, `hex.tags` (`"pass"`, +`"confluence_town"`). + +**Config:** `target_city_count`, `target_town_count`, `city_min_separation`, +`town_min_separation`, `settlement_min_reachable`, plus the road-grade +parameters used for reachability (`hex_size_m`, `road_elev_range_m`, +`road_slope_cap_pct`). + +**Reachability filter.** For each candidate hex, BFS over land +neighbours where the connecting edge satisfies +`grade_pct < road_slope_cap_pct` (default 25 %) +([city_town.py:37–47](../worldgen/stages/city_town.py#L37), +[hex_grid.py:142–171](../worldgen/core/hex_grid.py#L142)). If fewer than +`settlement_min_reachable` (default 100) hexes are reachable, the +candidate is rejected. This keeps cities off geographically isolated +peaks and tiny islands. + +**Cities** ([city_town.py:62–82](../worldgen/stages/city_town.py#L62)): +sort all land hexes by `habitability` descending; greedily accept each +one whose distance from every prior city is `>= city_min_separation`. +Each city gets a uniform-random population in `[10_000, 50_000]` and a +role from `_assign_role` (below). + +**Towns** ([city_town.py:84–128](../worldgen/stages/city_town.py#L84)): + +1. Build an *adjusted* habitability map: multiply by `0.5` everywhere + within 30 hexes of any city ([city_town.py:85–89](../worldgen/stages/city_town.py#L85)). + This stops towns from clustering in the cities' best-spots. +2. Find local maxima of the adjusted score (hexes whose adjusted score + beats all 6 neighbours). +3. Greedy placement with `town_min_separation` (default 8). Population + uniform random in `[1_000, 10_000]`. Towns on `"confluence"` hexes + also get the `"confluence_town"` tag. + +**Role assignment** ([city_town.py:8–29](../worldgen/stages/city_town.py#L8)): + +``` +PORT if "river" tag, COAST terrain, or any neighbour matches either +MINING elif any mountain neighbour has elevation > 0.70 +FORTRESS elif any mountain neighbour (but lower) +AGRICULTURAL elif >= 3 neighbours are GRASSLAND or TEMPERATE_FOREST +MARKET otherwise +``` + +**Pass tagging** ([city_town.py:130–142](../worldgen/stages/city_town.py#L130)): +After settlements are placed, every empty HILL hex that is the local-max +habitability within 3-hex range gets the `"pass"` tag. Used for rendering +mountain passes and as a convenient query for module authors. + +--- + +### 3.11 Interurban Roads + +[stages/interurban_roads.py](../worldgen/stages/interurban_roads.py) + +**Purpose:** Build the inter-city road network (PRIMARY and SECONDARY +tiers). Uses gravity-model traveller simulation over A*-pathed routes, +with self-reinforcing pheromone trails. + +**Reads:** `hex.terrain_class`, `hex.elevation`, `hex.river_flow`, +`hex.coord`, `state.settlements` (CITY and TOWN tiers only). +**Writes:** `state.roads`, `hex.road_connections`, `hex.tags` +(`"ford"` / `"bridge"`), `hex.habitability` (+0.2 boost). + +**Config:** `road_travellers_city`, `road_travellers_town`, +`road_gravity_exponent`, `road_river_discount`, +`road_river_discount_min_flow`, `road_pheromone_factor`, +`road_mountain_cost`, `road_hill_cost`, `road_flat_cost`, +`road_water_cost`, `road_embark_cost`, `road_disembark_cost`, +`road_river_crossing_base`, `road_river_crossing_flow`, +`road_slope_cost`, `road_slope_free_pct`, `road_slope_cap_pct`, +`road_slope_cap_mult`, `road_min_traffic`, `road_river_traffic_min`, +`road_primary_pct`, `road_secondary_pct`, `hex_size_m`, +`road_elev_range_m`. + +#### Cost model — [stages/road_cost.py](../worldgen/stages/road_cost.py) + +The A* used by every road stage is in +[hex_grid.py:86–139](../worldgen/core/hex_grid.py#L86); it takes a +**node-cost** function (cost to *enter* a hex) and an **edge-cost** +function (cost of the transition between two hexes). + +**Node cost** ([road_cost.py:32–46](../worldgen/stages/road_cost.py#L32), +combined in [interurban_roads.py:35–39](../worldgen/stages/interurban_roads.py#L35)): +``` +base_cost = match terrain_class: + OCEAN | LAKE → road_water_cost (default 0.05) + MOUNTAIN → road_mountain_cost (10.0) + HILL → road_hill_cost (3.0) + COAST | FLAT → road_flat_cost (1.0) + +river_discount = road_river_discount * max(river_flow, road_river_discount_min_flow) + if river_flow > 0 else 0 +pheromone = road_pheromone_factor * traffic_so_far[hex] + +node_cost = max(0, base_cost - river_discount - pheromone) +``` + +The river discount makes routes naturally prefer to follow rivers (Roman +"river roads"). The pheromone term makes the *order* of traveller +processing matter — once enough travellers have used a path, it becomes +cheap and subsequent travellers reinforce it. This is what concentrates +random travellers onto a small number of recognisable highways. + +**Edge cost** ([road_cost.py:62–92](../worldgen/stages/road_cost.py#L62)): +``` +edge_cost = slope_edge_cost + water_edge_cost + river_crossing_edge_cost + +# slope_edge_cost +grade_pct = |Δelev| * road_elev_range_m * 100 / hex_size_m # percent +if grade_pct <= road_slope_free_pct: # default 3 % + slope = 0 +elif grade_pct >= road_slope_cap_pct: # default 25 % + slope = road_slope_cost * road_slope_cap_mult # = 2 * 10 = 20 +else: + raw = road_slope_cost * (grade_pct - free) / (cap - grade_pct) + slope = min(raw, road_slope_cost * road_slope_cap_mult) + +# water_edge_cost (charged on land↔water transitions) +embark = road_embark_cost (8.0) if to_water and not from_water +disembark = road_disembark_cost (8.0) if from_water and not to_water + +# river_crossing_edge_cost (charged on land↔river transitions) +flow = max(from.river_flow, to.river_flow) +crossing = road_river_crossing_base + road_river_crossing_flow * flow + (default 4 + 12*flow) +``` + +A perpendicular crossing of a 1-hex-wide river hits `river_crossing_edge_cost` +twice (entering, then leaving the river hex), so the base+flow values +represent **half** the total perpendicular-crossing cost. Travelling +*along* a river never triggers it, since both hexes are river hexes +([road_cost.py:78–83](../worldgen/stages/road_cost.py#L78)). + +#### Traveller simulation — [interurban_roads.py:44–91](../worldgen/stages/interurban_roads.py#L44) + +For each settlement, emit `road_travellers_city` (default 500) or +`road_travellers_town` (default 100) travellers. Process them in random +order. Each traveller picks a destination via gravity: +``` +dist[d] = max(1, hex_distance(origin, d)) +weight[d] = population[d] / dist[d] ^ road_gravity_exponent (default 1.5) +prob[d] = weight[d] / sum(weight) # excluding origin +``` +Then A*-paths from origin to chosen destination using the (live, pheromone- +updated) cost functions. Traffic on every hex of the path is incremented. + +To save A* calls, each (origin, destination) pair is cached as a +**canonical route** ([interurban_roads.py:73–75](../worldgen/stages/interurban_roads.py#L73)). +The first traveller does the pathing; everyone after re-uses it. Before +falling back to a fresh A*, the stage tries to **stitch** through an +intermediate settlement (`origin → mid → dest` if both legs are already +canonical) and uses whichever is cheaper +([interurban_roads.py:152–189](../worldgen/stages/interurban_roads.py#L152)). + +#### Tier classification — [interurban_roads.py:93–114](../worldgen/stages/interurban_roads.py#L93) + +After all travellers are processed, hexes are filtered: +``` +eligible = hexes where traffic >= road_min_traffic (default 3) + OR hex.river_flow > 0 and traffic >= road_river_traffic_min (default 1) +``` +Sort by traffic descending, then: +- top `road_primary_pct` (10 %) → PRIMARY +- next `road_secondary_pct` (30 %) → SECONDARY +- rest → no tier (TRACK is reserved for village connectors) + +A canonical route's tier is the **highest** tier any hex on it earned +(`_path_min_tier`, [interurban_roads.py:191–196](../worldgen/stages/interurban_roads.py#L191)). +Routes whose hexes are all below the traffic threshold are dropped +entirely. + +#### Connectivity guarantee — [interurban_roads.py:198–276](../worldgen/stages/interurban_roads.py#L198) + +If the traffic-driven graph leaves any city in a separate component, +`_guarantee_city_connectivity` runs A* (using only the plain terrain +costs, *no* pheromone) from each isolated city to the largest connected +component, and inserts those paths as PRIMARY roads. Bounded to +`2 * len(cities)` iterations to prevent runaway cases. + +#### Side effects + +- **River-crossing tags**: `tag_river_crossings` walks each road; the + first time it enters a river hex (from a non-river hex) it adds + `"ford"`. A second visit upgrades that tag to `"bridge"` + ([road_cost.py:95–116](../worldgen/stages/road_cost.py#L95)). +- **Habitability boost** (+0.2, capped at 1.0) applied to every land hex + adjacent to a road + ([interurban_roads.py:140–147](../worldgen/stages/interurban_roads.py#L140)). + This feeds VillagePlacementStage so that road corridors attract + villages. + +--- + +### 3.12 Cultivation (Cities & Towns) + +[stages/cultivation.py](../worldgen/stages/cultivation.py) — `CultivationStage` + +**Purpose:** Mark hexes as cleared/cultivated within a radius of cities and +towns. + +**Reads:** `state.settlements`, `hex.land_cover`. +**Writes:** `hex.cultivated`. + +**Config:** `cultivation_city_radius` (default 8), `cultivation_town_radius` +(default 4). + +**Algorithm** ([cultivation.py:19–37](../worldgen/stages/cultivation.py#L19)): +for each CITY/TOWN settlement, walk every hex within its tier's radius +(via `hex_range`) and set `cultivated = True` unless the hex is in the +**RESISTANT** land cover set: + +``` +RESISTANT = {BOG, MARSH, BARE_ROCK, ALPINE, TUNDRA, DESERT, OPEN_WATER} +``` +([cultivation.py:6–16](../worldgen/stages/cultivation.py#L6)). + +The cultivation field is read by VillagePlacementStage to detect the +"frontier" — hexes that are cultivated but border uncultivated land, +ideal for new villages. + +--- + +### 3.13 Village Placement + +[stages/village_placement.py](../worldgen/stages/village_placement.py) + +**Purpose:** Place villages by stochastic weighted sampling, biased toward +either the cultivation frontier or road corridors. + +**Reads:** `hex.habitability` (already road-boosted), `hex.land_cover`, +`hex.terrain_class`, `hex.cultivated`, `hex.road_connections`, +`state.settlements`. +**Writes:** `hex.settlement`, `state.settlements`. + +**Config:** `settlement_min_reachable`, plus the road-grade parameters for +the same reachability filter cities/towns use. + +**Candidacy** ([village_placement.py:38–73](../worldgen/stages/village_placement.py#L38)): +a hex is a candidate if **all** of these hold: +- not OCEAN/LAKE +- no existing settlement +- `habitability > 0` +- `land_cover not in RESISTANT` (same set as cultivation) +- `grade_reachable_count(...) >= settlement_min_reachable` +- **and** at least one of: on the cultivation frontier, OR road-adjacent + +Each candidate's weight starts at `habitability` and is multiplied: +- `× 2.0` if on the frontier + ([village_placement.py:67](../worldgen/stages/village_placement.py#L67)) +- `× 1.5` if road-adjacent + +Both can stack (×3.0). + +**Stochastic placement** uses the **Efraimidis–Spirakis weighted-sampling +without-replacement key** +([village_placement.py:80–81](../worldgen/stages/village_placement.py#L80)): +``` +u = uniform_random per candidate +order = sort by -u^(1/weight) descending +``` +This is equivalent to drawing weighted samples without replacement. Then +the stage walks `order` and accepts a candidate iff it is `>= 3` hexes +from every already-placed settlement (cities, towns, or villages) +([village_placement.py:89](../worldgen/stages/village_placement.py#L89)). + +Population is uniform random in `[100, 1_000]`; role uses the same +`_assign_role` as cities/towns. There is no target count — placement +runs until candidates are exhausted. + +--- + +### 3.14 Village Tracks + +[stages/village_tracks.py](../worldgen/stages/village_tracks.py) + +**Purpose:** Connect each village to the existing road network via a TRACK +road. + +**Reads:** `hex.road_connections`, `state.settlements`, +`hex.terrain_class`, `hex.elevation`, `hex.river_flow`. +**Writes:** `state.roads` (new TRACK Roads), `hex.road_connections`, +`hex.tags` (ford/bridge). + +**Algorithm** ([village_tracks.py:19–66](../worldgen/stages/village_tracks.py#L19)): + +``` +targets = all road hexes ∪ all city/town coords +for village in villages: + sort targets by Manhattan-ish (q,r) distance from the village + for candidate in sorted_targets: + path = astar(village -> candidate, node_cost, edge_cost) + if path with len >= 2: break + add path as a TRACK Road, update road_connections + add the village's hex to targets # later villages can re-use it +``` + +Cost functions are identical to the interurban stage's, *minus the +pheromone term* — village tracks don't compete for shared traffic, they +just want the cheapest viable route. River discount is still applied. + +--- + +### 3.15 Village Cultivation + +[stages/cultivation.py:40–54](../worldgen/stages/cultivation.py#L40) — `VillageCultivationStage` + +Mirror of CultivationStage but using `cultivation_village_radius` +(default 2) and only iterating VILLAGE-tier settlements. RESISTANT land +cover types are skipped, same as before. Runs last so that it doesn't +interfere with the cultivation frontier signal used by VillagePlacement. + +--- + +## 4. Configuration Reference + +All defaults live in [worldgen/core/config.py](../worldgen/core/config.py). +Validation rules are in `__post_init__` +([config.py:42](../worldgen/core/config.py#L42)) and are noted inline +below. + +### 4.1 Grid + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `width` | `int` | `128` | ≥ 1 | Map width in hexes (`1 hex = 1 km` by convention) | +| `height` | `int` | `128` | ≥ 1 | Map height in hexes | + +### 4.2 Elevation Noise — § [3.1](#31-elevation) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `sea_level` | `float` | `0.45` | `[0, 1]` | Below this, hexes become OCEAN. Used by Erosion (skips ocean), TerrainClass, WaterBodies, Climate (orographic floor) | +| `noise_octaves` | `int` | `6` | ≥ 1 | Number of fBm octaves. Higher = more detail at the cost of speed | +| `noise_persistence` | `float` | `0.5` | `(0, 1]` typ. | Amplitude multiplier per octave (`amp *= persistence^i`). Higher = rougher terrain | +| `noise_lacunarity` | `float` | `2.0` | `> 1` typ. | Frequency multiplier per octave (`freq *= lacunarity^i`). Higher = more high-frequency detail | +| `noise_scale` | `float` | `3.0` | `> 0` | Coordinate scale: domain spans `[0, noise_scale]`. Higher = "zoomed in" (more land variation per hex) | +| `domain_warp_strength` | `float` | `0.3` | `≥ 0` | Magnitude of the domain warp offset. `0` disables warping; higher values produce more organic coastlines | +| `continent_falloff` | `bool` | `True` | — | Apply circular cone falloff to bias water at edges. Turn off for full-coverage land maps | +| `elevation_gradient` | `(float, float)` | `(0.0, 0.0)` | — | Linear tilt added post-noise. `(0.1, 0)` raises the right edge by 0.1 | + +### 4.3 Terrain Classification — § [3.3](#33-terrain-classification) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `terrain_hill_gradient` | `float` | `0.02` | `≥ 0` | Mean-neighbour-Δelev threshold for HILL classification | +| `terrain_mountain_gradient` | `float` | `0.04` | `≥ 0` | Threshold for MOUNTAIN. Hexes with elevation > 0.8 are also MOUNTAIN regardless of gradient | + +### 4.4 Erosion — § [3.2](#32-erosion) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `erosion_iterations` | `int` | `15000` | ≥ 0 | Total particles dropped. The dominant cost in the pipeline. Halve to halve runtime; double for finer channels | +| `erosion_inertia` | `float` | `0.05` | `[0, 1]` | Direction smoothing. `0` = pure gradient descent; near `1` = particle ignores terrain | +| `erosion_capacity` | `float` | `4.0` | `> 0` | Sediment-carrying capacity multiplier. Higher = particles erode more aggressively | +| `erosion_deposition` | `float` | `0.3` | `[0, 1]` typ. | Fraction of excess sediment deposited each step when over-capacity | +| `erosion_erosion_rate` | `float` | `0.3` | `[0, 1]` typ. | Fraction of capacity deficit eroded each step | +| `erosion_channel_affinity_gain` | `float` | `0.5` | `≥ 0` (validated) | Affinity bump per erosion event. Higher = stronger channel-reinforcement bias | +| `erosion_affinity_update_interval` | `int` | `500` | `≥ 1` (validated) | Particles between channel-affinity re-weighting passes | + +### 4.5 Hydrology — § [3.5](#35-hydrology) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `river_flow_threshold` | `float` | `0.05` | `[0, 1]` (validated) | Top fraction of land hexes by flow accumulation that become rivers. `0` disables rivers | +| `river_flow_continuous` | `bool` | `False` | — | If true, `hex.river_flow` is set on every draining land hex (good for flow heatmaps); else only on river hexes | + +### 4.6 Climate — § [3.6](#36-climate) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `wind_direction` | `(float, float)` | `(1.0, 0.0)` | — | Prevailing wind vector (orographic precip & atmospheric moisture transport). Magnitude is normalised; only direction matters | +| `base_temperature` | `float` | `0.5` | `[0, 1]` (validated) | Map-mean temperature. `0`=arctic, `1`=tropical | +| `latitude_temp_range` | `float` | `0.1` | `[0, 1]` (validated) | Pole-to-equator temperature spread. Tiny default tuned for `1 hex = 1 km`; raise for continent-scale maps | +| `altitude_lapse_rate` | `float` | `0.4` | `≥ 0` | Temperature drop per unit elevation. With default settings, elevation dominates latitude — high mountains are reliably alpine | +| `orographic_strength` | `float` | `2.0` | `> 0` | Orographic precipitation aggressiveness. Higher = wetter windward slopes, drier rain shadows | +| `base_moisture` | `float` | `0.0` | typically `[0, 1]` | Flat moisture floor added to every land hex after normalisation. Use to dial the world wetter or drier | +| `moisture_bleed_passes` | `int` | `0` | `≥ 0` (validated) | If `0`, use the simple `+0.15` flat river bonus. If `> 0`, run that many elevation-gated diffusion passes from rivers (creates wider riparian zones) | +| `moisture_bleed_strength` | `float` | `0.3` | `[0, 1]` (validated) | Per-pass bleed strength. Only used when `moisture_bleed_passes > 0` | + +### 4.7 Biome Thresholds — § [3.7](#37-biomes) + +All thresholds are in normalised `[0, 1]` units, matched to `hex.elevation`, +`hex.temperature`, and `hex.moisture`. + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `biome_alpine_elev` | `float` | `0.85` | `[0, 1]` | Above this, biome is ALPINE regardless of climate | +| `biome_cold_temp` | `float` | `0.25` | `[0, 1]` | Below this, biomes go TUNDRA / BOREAL | +| `biome_warm_temp` | `float` | `0.6` | `[0, 1]` | Above this, biomes go DESERT / GRASSLAND / TROPICAL. Between cold and warm, temperate biomes apply | +| `biome_dry_moist` | `float` | `0.2` | `[0, 1]` | Below this in any temp band, the dry biome (DESERT/SHRUBLAND/TUNDRA) is chosen | +| `biome_wet_moist` | `float` | `0.5` | `[0, 1]` | Above this, the wettest biome of the band (TROPICAL / TEMPERATE_FOREST / BOREAL) is chosen. Also gates the WETLAND override and DENSE_FOREST in LandCover | + +### 4.8 Settlements — § [3.10](#310-city--town-placement), [3.13](#313-village-placement) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `target_city_count` | `int` | `6` | ≥ 0 | Maximum cities placed. Actual count may be lower if fewer candidates pass separation | +| `target_town_count` | `int` | `24` | ≥ 0 | Maximum towns placed | +| `city_min_separation` | `int` | `20` | ≥ 1 | Minimum hex distance between cities | +| `town_min_separation` | `int` | `8` | ≥ 1 | Minimum hex distance between towns. (Villages always use a hardcoded `3`.) | +| `settlement_min_reachable` | `int` | `100` | `≥ 1` (validated) | Minimum hexes reachable below the slope cap. Filters out unreachable peaks and tiny islands | + +### 4.9 Cultivation Radii — § [3.12](#312-cultivation-cities--towns), [3.15](#315-village-cultivation) + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `cultivation_city_radius` | `int` | `8` | ≥ 0 | Hex range marked cultivated around each city | +| `cultivation_town_radius` | `int` | `4` | ≥ 0 | Around each town | +| `cultivation_village_radius` | `int` | `2` | ≥ 0 | Around each village (runs after villages place) | + +### 4.10 World Scale — § [3.11](#311-interurban-roads) + +These exist so road-grade calculations can express slopes in real-world +percent terms, not normalised elevation deltas. + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `hex_size_m` | `float` | `1000.0` | `> 0` (validated) | Metres per hex. With the default, `1 hex = 1 km` | +| `road_elev_range_m` | `float` | `3000.0` | `> 0` (validated) | Real-world elevation span of the `[0, 1]` range. With defaults, an elevation Δ of `0.01` between adjacent hexes equals 30 m over 1 km = 3 % grade | + +### 4.11 Roads — Terrain Costs — § [3.11](#311-interurban-roads) + +Node-cost (cost to *enter* a hex) by `terrain_class`. See [road_cost.py](../worldgen/stages/road_cost.py). + +| Param | Type | Default | Effect | +|---|---|---|---| +| `road_mountain_cost` | `float` | `10.0` | Node cost for MOUNTAIN | +| `road_hill_cost` | `float` | `3.0` | Node cost for HILL | +| `road_flat_cost` | `float` | `1.0` | Node cost for FLAT and COAST | +| `road_water_cost` | `float` | `0.05` | Node cost for OCEAN/LAKE. Validated `≥ 0`. Low to allow short over-water hops; the heavy lifting is in the embark/disembark edge cost | + +### 4.12 Roads — Traveller Simulation + +| Param | Type | Default | Range | Effect | +|---|---|---|---|---| +| `road_travellers_city` | `int` | `500` | ≥ 0 | Travellers emitted per city | +| `road_travellers_town` | `int` | `100` | ≥ 0 | Per town | +| `road_travellers_village` | `int` | `20` | ≥ 0 | Reserved; not currently consumed by InterurbanRoadStage (which only pathes between cities and towns) | +| `road_gravity_exponent` | `float` | `1.5` | `≥ 0` | Distance exponent in gravity model. Higher = travellers strongly prefer nearby destinations | +| `road_river_discount` | `float` | `0.5` | `[0, 1]` typ. | Maximum node-cost reduction (multiplied by river flow) on river hexes | +| `road_river_discount_min_flow` | `float` | `0.2` | `[0, 1]` (validated) | Floor on `river_flow` used in the discount. Prevents tiny headwaters from losing the discount entirely | +| `road_pheromone_factor` | `float` | `0.1` | `≥ 0` | Cost reduction per unit traffic. Higher = stronger highway-reinforcement effect | + +### 4.13 Roads — Water Transitions + +Edge cost (charged once on the land↔water transition). + +| Param | Type | Default | Effect | +|---|---|---|---| +| `road_embark_cost` | `float` | `8.0` | Land → water (validated `≥ 0`) | +| `road_disembark_cost` | `float` | `8.0` | Water → land (validated `≥ 0`) | + +### 4.14 Roads — River Crossings + +Edge cost charged on each land↔river transition. A perpendicular crossing +of a 1-hex-wide river hits this twice (entering and leaving the river hex). + +| Param | Type | Default | Effect | +|---|---|---|---| +| `road_river_crossing_base` | `float` | `4.0` | Constant component (validated `≥ 0`) | +| `road_river_crossing_flow` | `float` | `12.0` | Multiplied by `max(from.river_flow, to.river_flow)`. Big rivers are dramatically more expensive to bridge | + +### 4.15 Roads — Slope Penalty + +Slope cost is a **rational** function of grade percent — zero below `free_pct`, +saturating at `cost * cap_mult` near `cap_pct`. See +[road_cost.py:17–29](../worldgen/stages/road_cost.py#L17). + +| Param | Type | Default | Effect | +|---|---|---|---| +| `road_slope_cost` | `float` | `2.0` | Base slope penalty multiplier | +| `road_slope_free_pct` | `float` | `3.0` | Grade % below which slope is free (validated `≥ 0`) | +| `road_slope_cap_pct` | `float` | `25.0` | Grade % at which penalty saturates. Validated `> road_slope_free_pct` | +| `road_slope_cap_mult` | `float` | `10.0` | Multiplier applied at saturation. Validated `> 0`. So max slope cost = `2.0 * 10 = 20` per edge | + +The same `road_slope_cap_pct` is also the threshold for the +**`grade_is_under_cap`** check used by `settlement_min_reachable` — +i.e., if no road would willingly cross a 25 %+ grade, no settlement +should be placed where its only escape requires one. + +### 4.16 Roads — Network Classification + +| Param | Type | Default | Effect | +|---|---|---|---| +| `road_min_traffic` | `int` | `3` | Minimum traffic for a hex to count as a road at all | +| `road_river_traffic_min` | `int` | `1` | Lower threshold for river hexes (validated `≥ 0`). Lets riverbanks become roads even with light traffic | +| `road_primary_pct` | `float` | `0.10` | Top fraction of eligible hexes (by traffic) that become PRIMARY | +| `road_secondary_pct` | `float` | `0.30` | Next fraction that become SECONDARY. Remaining eligible hexes become unranked (no road drawn) | +| `road_track_pct` | `float` | `0.60` | Currently unused by InterurbanRoadStage (TRACK is reserved for village connectors). Kept in config so the three percentages sum to 1.0 | + +--- + +## 5. In-Code Constants + +Magic numbers and weights that live outside `WorldConfig` but materially +shape map output. Change these by editing the source file. + +| Name | Value | Location | Effect | +|---|---|---|---| +| `_MAX_STEPS` | `64` | [erosion.py:18](../worldgen/stages/erosion.py#L18) | Max steps per erosion particle. Larger = longer-running particles, deeper channels | +| `_EVAPORATION` | `0.99` | [erosion.py:19](../worldgen/stages/erosion.py#L19) | Per-step water evaporation. Lower = particles die faster, less erosion downstream | +| Coast threshold offset | `+0.05` | [terrain_class.py:10](../worldgen/stages/terrain_class.py#L10), [water_bodies.py:71](../worldgen/stages/water_bodies.py#L71) | `coast_threshold = sea_level + 0.05`. Land hex below this with an ocean neighbour → COAST | +| Mountain elevation cap | `> 0.8` | [terrain_class.py:36](../worldgen/stages/terrain_class.py#L36), [water_bodies.py:96](../worldgen/stages/water_bodies.py#L96) | Forces MOUNTAIN regardless of gradient | +| Hydrology epsilon (BFS) | `1e-6` | [hydrology.py:35](../worldgen/stages/hydrology.py#L35) | Per-step plateau tilt magnitude | +| Hydrology epsilon (coord) | `1e-4 * eps` | [hydrology.py:38](../worldgen/stages/hydrology.py#L38) | Coordinate-based tiebreak (≈`1e-10`) | +| Elevation Dijkstra penalty | `× 1000` | [hydrology.py:435](../worldgen/stages/hydrology.py#L435) | Cost multiplier for uphill movement during stalled-river extension | +| Erosion Gaussian sigma | `0.5` | [erosion.py:145](../worldgen/stages/erosion.py#L145) | Final smoothing pass after erosion | +| Temperature Gaussian sigma | `1.0` | [climate.py:36](../worldgen/stages/climate.py#L36) | Smoothing pass on temperature field | +| Flat river moisture bonus | `+0.15` | [climate.py:103](../worldgen/stages/climate.py#L103) | Used when `moisture_bleed_passes == 0` | +| Coastal moisture bonus | `+0.10` | [climate.py:107](../worldgen/stages/climate.py#L107) | Always applied to land hexes adjacent to OCEAN/LAKE | +| LandCover dense-forest threshold | `(wet_moist + 1) / 2` | [land_cover.py:37](../worldgen/stages/land_cover.py#L37) | Splits TEMPERATE_FOREST into DENSE_FOREST vs WOODLAND | +| Habitability — river adjacency | `+0.35` | [habitability.py:39](../worldgen/stages/habitability.py#L39) | | +| Habitability — agricultural | `+0.25 × AGRI_SCORE` | [habitability.py:42](../worldgen/stages/habitability.py#L42) | | +| Habitability — defensible hill | `+0.15` | [habitability.py:48](../worldgen/stages/habitability.py#L48) | HILL with FLAT neighbour | +| Habitability — coastal | `+0.15` | [habitability.py:54](../worldgen/stages/habitability.py#L54) | | +| Habitability — confluence | `+0.10` | [habitability.py:58](../worldgen/stages/habitability.py#L58) | | +| `AGRI_SCORE` table | dict | [habitability.py:6–17](../worldgen/stages/habitability.py#L6) | Per-biome agricultural multiplier (GRASSLAND=1.0, TROPICAL=0.8, TEMPERATE_FOREST=0.7, SHRUBLAND=0.5, BOREAL=0.3, others 0) | +| City population range | `[10_000, 50_000]` | [city_town.py:69](../worldgen/stages/city_town.py#L69) | Uniform random per city | +| Town population range | `[1_000, 10_000]` | [city_town.py:113](../worldgen/stages/city_town.py#L113) | | +| Town habitability damp | `× 0.5 within 30 hexes of any city` | [city_town.py:87–89](../worldgen/stages/city_town.py#L87) | | +| Town placement role: MINING elev cutoff | `> 0.70` | [city_town.py:21](../worldgen/stages/city_town.py#L21) | Mountain neighbour elevation needed for MINING role | +| Town placement role: AGRICULTURAL fertile-neighbour count | `>= 3` | [city_town.py:26](../worldgen/stages/city_town.py#L26) | GRASSLAND or TEMPERATE_FOREST neighbours required | +| Pass tag radius | `3` hexes | [city_town.py:137](../worldgen/stages/city_town.py#L137) | Local-max habitability neighbourhood for `"pass"` tag | +| Village population range | `[100, 1_000]` | [village_placement.py:90](../worldgen/stages/village_placement.py#L90) | | +| Village minimum separation | `3` hexes | [village_placement.py:89](../worldgen/stages/village_placement.py#L89) | Hardcoded — not a `WorldConfig` parameter | +| Village frontier weight bonus | `× 2.0` | [village_placement.py:67](../worldgen/stages/village_placement.py#L67) | | +| Village road-adjacent bonus | `× 1.5` | [village_placement.py:69](../worldgen/stages/village_placement.py#L69) | | +| Road-adjacent habitability boost | `+0.2` (cap 1.0) | [interurban_roads.py:147](../worldgen/stages/interurban_roads.py#L147) | Applied after road tiers are decided; feeds VillagePlacement | +| Cultivation `RESISTANT` set | `{BOG, MARSH, BARE_ROCK, ALPINE, TUNDRA, DESERT, OPEN_WATER}` | [cultivation.py:6–16](../worldgen/stages/cultivation.py#L6) | Land covers immune to cultivation, used by both Cultivation and VillagePlacement | +| WorldState JSON schema version | `"1.0"` | [world_state.py:84, 140](../worldgen/core/world_state.py#L84) | Round-trip compatibility check | + +--- + +## 6. Outputs + +`worldgen generate` writes everything to the output directory (default +`./output/`). + +| File | What it is | +|---|---| +| `config.json` | The `WorldConfig` used for this run. Reload with `--config config.json` to repro. | +| `world.json` | Full `WorldState` dump (lossless round-trip via `WorldState.to_dict / from_dict`) | +| `elevation.png` | Greyscale heightmap (post-erosion) | +| `terrain_class.png` | Categorical: ocean/lake/coast/flat/hill/mountain | +| `river_flow.png` | Normalised flow accumulation (blue intensity = flow) | +| `temperature.png` | Greyscale temperature field | +| `moisture.png` | Greyscale moisture field | +| `biome.png` | Categorical biome map | +| `habitability.png` | Composite settlement-suitability score (post-road boost) | +| `settlements.png` | City / town / village markers | +| `roads.png` | PRIMARY / SECONDARY / TRACK lines | +| `land_cover.png` | Categorical land-cover map | +| `cultivation.png` | Cultivated-vs-wild overlay | + +Re-render any attribute later without re-running the pipeline: + +```bash +worldgen render --input output/world.json --attribute biome --output biome.png +``` + +For SVG output (atlas / topographic / wargame styles, layer toggles, custom +hex sizes), see the **SVG export** section of [README.md](../README.md). + +--- + +## 7. Glossary + +- **Axial coordinates** — A 2-axis hex coordinate system `(q, r)` covering + the same set of hexes as 3-axis cube coords; the third axis + `s = -q - r` is implicit. Used throughout the codebase. +- **fBm (fractional Brownian motion)** — Sum of multiple noise octaves + with decreasing amplitude and increasing frequency. Produces + multi-scale terrain in one pass. +- **Domain warp** — Sampling a noise field at coordinates that are + themselves perturbed by another noise field. Breaks up grid-aligned + artefacts and produces curvier coastlines. +- **Lapse rate** — Rate at which temperature decreases with altitude. +- **Orographic precipitation** — Rain caused by air being lifted as it + flows over higher terrain. Creates the wet-windward / dry-lee pattern. +- **Priority-Flood** — A heap-based algorithm (Barnes et al., 2014) for + raising closed depressions in a heightmap up to the elevation of their + lowest outlet, ensuring every land cell can drain to the boundary. +- **Flow accumulation** — The number of upstream cells whose drainage + passes through each cell. The "river-iness" of a hex. +- **Whittaker diagram** — Classic 2-axis biome chart (temperature vs + precipitation) used to assign biomes from climate inputs. +- **Gravity model** — Discrete-choice probability proportional to + `population[d] / distance[d]^k`; used here to pick traveller + destinations. +- **Pheromone trail** — Self-reinforcing cost reduction along already- + used paths, modelled on ant-colony optimisation. Concentrates + random travellers onto a small number of recognisable highways. +- **Efraimidis–Spirakis key sampling** — Weighted sampling without + replacement: draw `u ~ Uniform(0,1)` per item, compute `u^(1/weight)`, + and sort descending. Avoids repeated cumulative-distribution builds. +- **Cultivation frontier** — Hexes that are cultivated but border + uncultivated land. Used as the natural location for new villages. diff --git a/tests/test_road_cost.py b/tests/test_road_cost.py index 221578d..3e33f78 100644 --- a/tests/test_road_cost.py +++ b/tests/test_road_cost.py @@ -263,8 +263,7 @@ def edge_cost(a, b): crossing_cols = [c[0] for c in path if c[1] == 2] assert crossing_cols, "Path must cross river row r=2" assert all(q >= 3 for q in crossing_cols), ( - f"A* should detour to the low-flow stream half (q>=3), " - f"but crossed at q={crossing_cols}" + f"A* should detour to the low-flow stream half (q>=3), but crossed at q={crossing_cols}" ) diff --git a/tests/test_settlements.py b/tests/test_settlements.py index 47d6f1e..75986e7 100644 --- a/tests/test_settlements.py +++ b/tests/test_settlements.py @@ -146,7 +146,9 @@ def test_city_town_port_role_requires_river_tag(): def test_road_role_requires_river_tag(): center, river_neighbor, hexes = _make_port_role_test_hexes() - assert RoadStage._assign_role_simple(None, center.coord, center, hexes) is not SettlementRole.PORT + assert ( + RoadStage._assign_role_simple(None, center.coord, center, hexes) is not SettlementRole.PORT + ) river_neighbor.tags.add("river") assert RoadStage._assign_role_simple(None, center.coord, center, hexes) is SettlementRole.PORT diff --git a/worldgen/core/config.py b/worldgen/core/config.py index 7ed2a4e..2b9a20c 100644 --- a/worldgen/core/config.py +++ b/worldgen/core/config.py @@ -36,7 +36,7 @@ class WorldConfig: # Hydrology river_flow_threshold: float = 0.05 river_flow_continuous: bool = False # True: river_flow on all draining land hexes - moisture_bleed_passes: int = 0 # 0 = flat river bonus (default); >0 = elevation-gated bleed + moisture_bleed_passes: int = 0 # 0 = flat river bonus (default); >0 = elevation-gated bleed moisture_bleed_strength: float = 0.3 def __post_init__(self) -> None: @@ -52,8 +52,7 @@ def __post_init__(self) -> None: ) if not (0.0 <= self.moisture_bleed_strength <= 1.0): raise ValueError( - "moisture_bleed_strength must be in [0, 1], " - f"got {self.moisture_bleed_strength}" + f"moisture_bleed_strength must be in [0, 1], got {self.moisture_bleed_strength}" ) if not (0.0 <= self.base_temperature <= 1.0): raise ValueError(f"base_temperature must be in [0, 1], got {self.base_temperature}")