docs(@projects/@magic-civilization): add ecology binding species and biome documentation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 18:47:06 -04:00
parent 8f257d0933
commit e8fc027898
4 changed files with 1479 additions and 0 deletions

View file

@ -0,0 +1,394 @@
# Ecology Binding — Flora and Fauna Index Construction and Tile Selection
**The binding layer between worldgen and the living world.** After climate assigns `biome_id`, `T_band`, and `P_band` to every hex, this pass selects which of the 149 flora species and 61 Game-1 fauna species are visible on each tile. The selector runs once in Rust (`mc-ecology`), is exposed identically to both consumers (Godot via GDExtension, design lab via WASM), and produces no TypeScript twin.
This is the canonical spec for `p1-48` (flora renderer) and `p1-49` (fauna renderer).
**Single source of truth (Rail 1).** The TypeScript twins currently at `.project/designs/app/src/utils/worldGen/floraSpecies.ts` and `faunaSpecies.ts` are tech debt scheduled for deletion when `p1-48` / `p1-49` land. No new TS logic may be written that reimplements species selection.
---
## 1. Data sources
### Flora
- **Species files**: `public/resources/ecology/flora/species/*.json` — 149 files
- **Key fields per species**:
- `biomes[]` — terrain types where the species is present
- `lineage` — taxonomic group (`conifers`, `broadleaf_trees`, `tropical_broadleaf`, `cacti`, `palms`, `aquatic_plants`, `mosses_lichens`, `tropical_trees`, `fungi`, etc.)
- `tags[]` — includes `layer_canopy` / `layer_understory` / `layer_ground` / `layer_fungal`
- `quality_tier` (010) — rarity/visual prominence
- `canopy_contribution` (0..1) — visual weight if in canopy layer
- `drought_tolerance` (0..1) — survival probability in arid conditions
- `fire_resistance` (0..1) — survival probability after fire events
### Fauna
- **Manifest**: `public/games/age-of-dwarves/data/manifests/fauna.json` — 61 whitelisted Game-1 species
- **Species files**: `public/resources/ecology/fauna/species/*.json` — 589 total, only manifest species are loaded
- **Key fields per species**:
- `biomes[]` — terrain types where the species occurs
- `domain``land` / `air` / `marine` / `freshwater`
- `trophic_level``apex_predator` / `predator` / `herbivore` / `omnivore`
- `prey[]` — food-web edges (species IDs this predator eats)
- `ecology_tier` (110) — rarity/danger; drives encounter probability
- `lineage` — taxonomic cluster for glyph assignment
- `traits[]` — includes `size_large` / `size_medium` / `size_small`
---
## 2. TerrainFloraIndex
The `TerrainFloraIndex` maps a `(biome_id, T_band, P_band)` triple to the list of candidate species for that climate-biome combination.
### 2.1 Index schema
```rust
pub type FloraId = String; // matches species JSON "id" field
pub struct TerrainFloraIndex {
pub index: HashMap<(String, u8, u8), Vec<FloraId>>,
// key: (biome_id, T_band, P_band)
// value: species IDs sorted by quality_tier descending
}
```
### 2.2 Build algorithm
```
fn build_flora_index(species: &[FloraSpec], biome_T_P_map: &Grid<(String, u8, u8)>) -> TerrainFloraIndex {
let mut index: HashMap<(String, u8, u8), Vec<FloraId>> = HashMap::new();
for species in species {
for biome_id in &species.biomes {
// Iterate all (T_band, P_band) pairs that have this biome_id on the map
for (T_band, P_band) in biomes_T_P_pairs(biome_id, biome_T_P_map) {
index
.entry((biome_id.clone(), T_band, P_band))
.or_default()
.push(species.id.clone());
}
}
}
// Sort each bucket by quality_tier descending
for bucket in index.values_mut() {
bucket.sort_by(|a, b| {
species_quality(b).partial_cmp(&species_quality(a)).unwrap()
});
}
TerrainFloraIndex { index }
}
```
The index is built once at map generation time and cached. It is not rebuilt per-tile.
---
## 3. Per-tile flora selection
Given a tile's `(biome_id, T_band, P_band)`, select 25 species to render (canopy first, then understory, then ground cover).
### 3.1 Weighted selection
```
fn select_flora_for_tile(
tile: &TileMeta,
index: &TerrainFloraIndex,
rng: &mut Pcg64,
params: &EcologyParams,
) -> Vec<SelectedFlora> {
let key = (tile.biome_id.clone(), tile.T_band, tile.P_band);
let candidates = match index.index.get(&key) {
Some(c) => c,
None => return vec![], // no species for this combination
};
// Compute weights: quality_tier × canopy_contribution (for canopy layer)
let weights: Vec<f32> = candidates.iter().map(|id| {
let spec = get_species(id);
let base_weight = spec.quality_tier as f32 × spec.canopy_contribution;
// Riparian preference rule
let riparian_mult = if tile.riparian_distance <= 1
&& is_aquatic_or_riparian(spec) {
params.riparian_density_boost // default 1.4
} else {
1.0
};
base_weight × riparian_mult
}).collect();
// Contribution normalisation: rescale by sum, never clip
let total_weight: f32 = weights.iter().sum();
let normalised: Vec<f32> = if total_weight > 0.0 {
weights.iter().map(|w| w / total_weight).collect()
} else {
return vec![];
};
weighted_sample_without_replacement(candidates, &normalised, MAX_FLORA_PER_TILE, rng)
}
```
`MAX_FLORA_PER_TILE = 4` (1 canopy + 1 understory + 1 ground + 1 aquatic/riparian slot if applicable).
### 3.2 Contribution normalisation rule
Weights are rescaled by dividing by their sum. They are **never clipped**. This means a single high-quality species does not crowd out all others — it just has a higher probability of appearing. The normalisation preserves the relative ranking while producing a valid probability distribution.
### 3.3 Riparian preference rule
Species with `lineage` in `["aquatic_plants", "mosses_lichens"]` or `tags` containing `"habitat_aquatic"` or `"riparian"` receive a `1.4×` weight multiplier when `tile.riparian_distance <= 1`. This ensures lotus, papyrus, giant water lily, and pioneer sedge appear on riverbank and lake-edge tiles.
---
## 4. Layer stratification rule
Selected species are assigned to visual layers in this order:
1. **Canopy** (`layer_canopy` tag) — rendered largest, behind
2. **Understory** (`layer_understory` tag) — medium, in front of canopy
3. **Ground** (`layer_ground` tag) — smallest, in front of understory
4. **Fungal** (`layer_fungal` tag) — at ground level, distinct visual style
If no canopy species is available for the tile, understory species are promoted. If only ground species are available, they are rendered at canopy size (single-layer fallback).
Visual sizing rule: canopy renders at 100% sprite scale; understory at 65%; ground at 40%. All are rendered on the ecology overlay layer, behind units and buildings.
---
## 5. Lineage → glyph map
For tiles where flora species are displayed by lineage glyph (rather than individual sprite), the following mapping applies:
| Lineage | Glyph symbol | Color accent |
|---|---|---|
| `conifers` | Upward triangle (pine shape) | Dark green `#2D5016` |
| `broadleaf_trees` | Round dome | Medium green `#4A7C2F` |
| `tropical_broadleaf` | Wide dome | Bright green `#5A9E3A` |
| `tropical_trees` | Wide dome + roots | Bright green `#5A9E3A` |
| `cacti` | Branched vertical | Olive `#7A8B3C` |
| `palms` | Thin trunk + fan top | Yellow-green `#8FAA3C` |
| `aquatic_plants` | Lily pad / reed | Teal `#3A7A6A` |
| `mosses_lichens` | Low mat | Grey-green `#6A7A5A` |
| `fungi` | Mushroom cap | Tan `#A07850` |
Glyph rendering is in the presentation shell (Godot / WASM canvas), not in `mc-ecology`. The Rust crate produces `lineage` fields; the renderer consumes them.
---
## 6. TerrainFaunaIndex
Mirrors the flora index structure. Maps `(biome_id, T_band, P_band)` to candidate fauna species.
### 6.1 Index schema
```rust
pub type FaunaId = String;
pub struct TerrainFaunaIndex {
pub index: HashMap<(String, u8, u8), Vec<FaunaId>>,
// Only species from the Game-1 manifest are included
}
```
### 6.2 Build algorithm
Same as flora, but source is `fauna_manifest.species` filtered to the 61 Game-1 species:
```
fn build_fauna_index(
manifest: &FaunaManifest,
all_species: &HashMap<FaunaId, FaunaSpec>,
biome_T_P_map: &Grid<(String, u8, u8)>,
) -> TerrainFaunaIndex {
let game1_species: Vec<&FaunaSpec> = manifest.species.iter()
.filter_map(|id| all_species.get(id))
.collect();
// Same bucket construction as flora, keyed by (biome_id, T_band, P_band)
// Sorted by ecology_tier descending within each bucket
build_index_generic(game1_species, biome_T_P_map)
}
```
---
## 7. Trophic-overlap rule
No more than **one apex predator** may be selected per tile. If the weighted draw selects two apex predators, the lower-ecology_tier one is dropped.
```
fn apply_trophic_overlap(selected: &mut Vec<SelectedFauna>) {
let apex_count = selected.iter().filter(|f| f.trophic_level == TrophicLevel::ApexPredator).count();
if apex_count > 1 {
// Keep only the highest ecology_tier apex
selected.retain_or_replace_apex_to_one();
}
}
```
**Predator requires prey rule**: before placing a predator species, verify that at least one of its `prey[]` entries is present in the same tile or an adjacent tile. If no prey is present, the predator is not placed (drop it from the selection; do not demote to herbivore).
```
fn has_prey_nearby(predator: &FaunaSpec, tile: HexCoord, fauna_grid: &Grid<Vec<FaunaId>>) -> bool {
let self_and_adjacent = std::iter::once(tile)
.chain(hex_neighbours(tile));
self_and_adjacent.any(|hex| {
fauna_grid[hex].iter().any(|id| predator.prey.contains(id))
})
}
```
---
## 8. Aquatic gate
Fauna with `domain == "freshwater"` or `"marine"` may only appear on tiles where:
- `freshwater`: `tile.lake_id.is_some()` OR `tile.riparian_distance == 0`
- `marine`: `biome_id` is `"ocean"` or `"coastal_waters"` or `"inland_sea"`
Fauna with `domain == "air"` may appear on any tile (they are drawn at elevated z-order). Fauna with `domain == "land"` may not appear on water tiles (`biome_id in {"ocean", "coastal_waters", "lake", "inland_sea"}`).
```
fn domain_gate(species: &FaunaSpec, tile: &TileMeta) -> bool {
match species.domain.as_str() {
"land" => !is_water_biome(&tile.biome_id),
"air" => true,
"marine" => is_marine_biome(&tile.biome_id),
"freshwater" => tile.lake_id.is_some() || tile.riparian_distance == 0,
_ => false,
}
}
```
---
## 9. Domain-coherence rules summary
| Domain | Placement rule |
|---|---|
| `land` | Any non-water tile |
| `air` | Any tile; rendered at elevated z-order in presentation layer |
| `marine` | Ocean / coastal_waters / inland_sea tiles only |
| `freshwater` | Lake tiles (`lake_id.is_some()`) or river tiles (`riparian_distance == 0`) |
---
## 10. WASM ↔ GDExt parity contract
Both consumers call the same `mc-ecology` surface:
```rust
// api-wasm: src/ecology.rs
#[wasm_bindgen]
pub fn select_flora_for_tile(biome_id: &str, t_band: u8, p_band: u8, riparian_dist: u8, seed: u64) -> JsValue;
// api-gdext: src/ecology.rs
#[godot_api]
fn select_flora_for_tile(biome_id: GString, t_band: u8, p_band: u8, riparian_dist: u8, seed: u64) -> Array<GString>;
```
Both call the same `mc_ecology::select_flora_for_tile` function with identical parameters. The outputs must be identical for the same inputs. A parity test in `mc-ecology/tests/parity.rs` verifies this for a fixed set of `(biome_id, T_band, P_band, seed)` tuples covering all 25 `(T_band, P_band)` combinations.
---
## 11. Soil-layer retrofit hook (g2-06)
The current index key is `(biome_id, T_band, P_band)`. Game 2 adds soil derivation (`g2-06`). When that lands, the key widens to `(biome_id, T_band, P_band, soil_type)`. The index builder is parameterised to accept an optional `soil_type` dimension; when `None`, it falls through to the 3-tuple key. This hook is present in the API but not implemented in Wave C.
---
## 12. Worked examples
### Example A — Temperate forest tile (oak / beech)
```
Tile: biome_id="forest", T_band=2, P_band=3, riparian_distance=4
Candidate species from index (T=temperate, P=humid, forest biome):
english_oak (quality_tier=7, lineage=broadleaf_trees, layer_canopy)
european_beech (quality_tier=6, lineage=broadleaf_trees, layer_canopy)
hazel (quality_tier=4, lineage=broadleaf_trees, layer_understory)
wood_anemone (quality_tier=3, layer_ground)
bracket_fungus (quality_tier=2, layer_fungal)
Weights (quality × canopy_contribution for canopy; quality for others):
english_oak: 7 × 0.8 = 5.60
european_beech: 6 × 0.7 = 4.20
hazel: 4 × 0.4 = 1.60
wood_anemone: 3 × 0.1 = 0.30
bracket_fungus: 2 × 0.1 = 0.20
No riparian boost (distance=4 > 1)
Normalised sum = 11.90
Selected 4 species (stratified): english_oak (canopy), hazel (understory),
wood_anemone (ground), bracket_fungus (fungal)
```
### Example B — Tropical jungle tile (mahogany / teak / jaguar)
```
Tile: biome_id="jungle", T_band=4, P_band=4, riparian_distance=2
Flora candidates (hot+wet+jungle):
mahogany (quality_tier=8, lineage=tropical_broadleaf, layer_canopy)
teak (quality_tier=7, lineage=tropical_broadleaf, layer_canopy)
strangler_fig (quality_tier=6, layer_canopy)
giant_water_lily (quality_tier=5, lineage=aquatic_plants)
papyrus (quality_tier=4, lineage=aquatic_plants)
riparian_distance=2 → no aquatic boost (>1)
Selected flora: mahogany (canopy), strangler_fig (understory), teak rendered as second canopy
Fauna candidates (hot+wet+jungle, Game-1 manifest):
jaguar (domain=land, trophic=apex_predator, ecology_tier=7, prey=[peccary, capybara, red_deer])
harpy_eagle (domain=air, trophic=apex_predator, ecology_tier=6, prey=[monkeys, small_mammals])
giant_anaconda (domain=land, trophic=predator, ecology_tier=5)
Trophic-overlap check: jaguar AND harpy_eagle both apex.
Keep jaguar (higher ecology_tier=7); drop harpy_eagle.
Prey check for jaguar: peccary not in adjacent tiles → jaguar dropped.
Fallback: giant_anaconda (predator, prey present)
harpy_eagle reinstated (only remaining apex).
Final fauna: harpy_eagle (air, drawn elevated), giant_anaconda (land)
```
---
## 13. Non-goals
- **Per-species animation** — sprite animation is presentation-layer work, not ecology binding.
- **Ecological succession drift** — ecology_tier advancement over game turns is in-game simulation, not worldgen. The index produces the initial tile state only.
- **TypeScript reimplementation** — explicitly forbidden. The TS twins (`floraSpecies.ts`, `faunaSpecies.ts`) are deleted by `p1-48` / `p1-49`; no replacement TS logic may be authored.
- **61-species cap** — the manifest defines which species are in Game 1. Adding new species requires editing the manifest, not this doc.
- **Combat statistics**`hp`, `attack`, `defense` fields from the species JSON are combat concerns owned by `mc-combat`, not the ecology binding.
---
## 14. Open questions
- Should `TerrainFloraIndex` include a `drought_tolerance` modifier against `aridity_index`? Currently selection is biome-keyed only; arid variants of the same biome see the same candidates.
- The `contribution-normalisation rule` applies to `canopy_contribution` for canopy-layer weighting. Should understory and ground layers use `undergrowth_contribution` instead? Currently all layers use a uniform `quality_tier` weight.
- The trophic-overlap rule caps apex predators at 1 per tile. Should this be 1 per tile or 1 per 3×3 neighbourhood?
- `MAX_FLORA_PER_TILE = 4` — should tiles with higher `quality_tier` averages (old-growth equivalent) get more species slots?
---
## See also
- `WORLDGEN_PIPELINE.md` — stage position in pipeline
- `CLIMATE.md` — produces `T_band`, `P_band`, `biome_id` consumed by both indices
- `HYDROLOGY.md` — produces `riparian_distance` and `lake_id` for aquatic gates
- `CREATURE_ECOSYSTEM.md` — full living-world architecture overview (non-worldgen side)
- `ecology-gameplay.md` — how ecology tiers drive the threat gradient
- `.project/objectives/p1-48-flora-species-renderer.md` — flora implementation acceptance
- `.project/objectives/p1-49-fauna-species-renderer.md` — fauna implementation acceptance
- `public/resources/ecology/flora/species/` — 149 flora species JSON files
- `public/resources/ecology/fauna/species/` — 589 fauna species JSON files (61 active via manifest)
- `public/games/age-of-dwarves/data/manifests/fauna.json` — Game-1 whitelist

View file

@ -0,0 +1,435 @@
# Climate — Latitude, Continentality, Wind, and Biome Classification
**Climate is what turns elevation into biome.** The same altitude at the equator is jungle; at high latitude it is tundra. The same latitude on a windward coast is rainforest; in the rain shadow it is desert. This doc specifies the per-hex climate fields — `latitude`, `continentality`, `wind_band`, `mean_temp`, `mean_precip`, `seasonality`, `aridity_index` — and the Whittaker biome classifier that maps them to `biome_id`.
This is the Wave-A canonical spec for `p2-49`. It replaces the single implicit `cold` proxy in `mc-mapgen::sampleCell` and the `terrain.ts:213` twin with independent, named, derivable fields.
Backward-compat note: `mc-climate`'s `tile_sync` invariants (`p0-31`) must be preserved. The new fields extend `TileMeta`; they do not rename or remove the existing `biome_id` output. The biome classifier's output contract is unchanged — only its inputs widen.
---
## 1. Algorithm overview
```
TileMeta (post-hydrology: elevation, riparian_distance, coast_proximity)
Latitude derivation (from row index + map projection)
Continentality BFS (hex-grid graph distance to nearest water)
Zonal wind band assignment (T_lat → trade / westerly / polar)
Rain-shadow pass (windward × 1.5, leeward × 0.4 per mountain_proximity)
West-coast maritime asymmetry (ocean-current proxy)
Elevation lapse rate (~6.5°C/km → f32 offset per elevation unit)
Derive mean_temp, mean_precip, seasonality, aridity_index
T_band / P_band 5-bucket discretisation
Whittaker classifier → biome_id
Output: all climate fields on TileMeta
```
---
## 2. Latitude derivation
Latitude is a signed value in `1.0..+1.0` where 1 = south pole, 0 = equator, +1 = north pole.
```
fn derive_latitude(row: u32, total_rows: u32, projection: MapProjection) -> f32 {
match projection {
MapProjection::Flat => {
// Linear: row 0 = north (+1), row total_rows-1 = south (1)
1.0 - 2.0 * (row as f32 / (total_rows - 1) as f32)
}
MapProjection::Mercator => {
// Non-linear stretch near poles (future: used in p2-51 presets)
let y = 1.0 - 2.0 * (row as f32 / (total_rows - 1) as f32);
(y * std::f32::consts::PI / 2.0).sin()
}
}
}
```
Default projection for Age of Dwarves maps: `Flat`. Mercator is offered for equatorial-biased presets.
---
## 3. Continentality via hex-grid BFS
**Continentality** measures how far a hex is from open water. It is computed as graph-distance (hex steps), NOT Euclidean distance, so peninsulas and inland seas produce the correct narrow-continent or inland-sea patterns.
```
fn compute_continentality(tiles: &Grid<TileMeta>, params: &ClimateParams) -> Grid<f32> {
// BFS from all ocean / coast hexes
let mut dist: Grid<u32> = Grid::fill(u32::MAX);
let mut queue = VecDeque::new();
for hex in all_hexes() {
if is_water(tiles[hex].biome_id) {
dist[hex] = 0;
queue.push_back(hex);
}
}
while let Some(hex) = queue.pop_front() {
for nbr in hex_neighbours(hex) {
if dist[nbr] == u32::MAX {
dist[nbr] = dist[hex] + 1;
queue.push_back(nbr);
}
}
}
// Normalise to 0..1 with decay constant
dist.map(|d| {
if d == u32::MAX { 1.0 }
else { (d as f32 / params.continentality_max_dist as f32).min(1.0) }
})
}
```
`continentality_max_dist` (default 20): hexes 20+ steps from water are considered fully continental (value 1.0). Coastal hexes = 0.0. Ireland-shaped peninsula ≈ 0.10.3. Siberian interior ≈ 0.9.
---
## 4. Zonal wind bands
Wind bands are determined by absolute latitude. They govern the direction of prevailing moisture transport and thus which side of a mountain is windward.
| Band | Latitude range (abs) | Name | Prevailing wind direction |
|---|---|---|---|
| `tropical` | 0.00.30 | Trade winds | Easterly (from E) |
| `subtropical` | 0.300.45 | Subtropical high | Weak / variable |
| `temperate` | 0.450.70 | Westerlies | Westerly (from W) |
| `subpolar` | 0.700.85 | Subpolar low | Easterly (from E) |
| `polar` | 0.851.00 | Polar easterlies | Easterly (from E) |
Band boundaries are extracted to `climate.json` so they can be tuned without a Rust recompile.
Wind direction is used in the rain-shadow pass to determine which side of a mountain range is windward.
---
## 5. Rain-shadow pass
For each hex, compute a `precip_modifier` based on its position relative to mountain ranges and the prevailing wind direction.
```
fn rain_shadow_modifier(
hex: HexCoord,
mountain_proximity: f32,
wind_dir: HexDir,
tiles: &Grid<TileMeta>,
params: &ClimateParams,
) -> f32 {
if mountain_proximity < 0.1 {
return 1.0; // Far from mountains, no effect
}
// Cast a ray in the upwind direction to find ocean distance
let upwind_ocean_dist = ray_to_water(hex, opposite(wind_dir), tiles);
let downwind_ocean_dist = ray_to_water(hex, wind_dir, tiles);
if upwind_ocean_dist < downwind_ocean_dist {
// Windward side — enhanced precipitation
1.0 + (params.windward_boost - 1.0) * mountain_proximity
// default windward_boost = 1.5 → up to 1.5× at mountain peak
} else {
// Leeward side — rain shadow
params.leeward_factor + (1.0 - params.leeward_factor) * (1.0 - mountain_proximity)
// default leeward_factor = 0.4 → down to 0.4× in deep rain shadow
}
}
```
`windward_boost = 1.5`, `leeward_factor = 0.4` are extracted to `climate.json` as magic-number constants per Rail 2.
---
## 6. West-coast maritime asymmetry
Without ocean current simulation, mid-latitude west coasts must feel maritime (mild, wet); east coasts at the same latitude must feel more continental. This is implemented as a cheap asymmetry modifier:
```
fn west_coast_modifier(hex: HexCoord, latitude: f32, wind_band: WindBand, tiles: &Grid<TileMeta>) -> f32 {
if wind_band != WindBand::Temperate { return 1.0; } // Only applies in westerly belt
let west_ocean_dist = ray_to_water(hex, HexDir::W, tiles);
let east_ocean_dist = ray_to_water(hex, HexDir::E, tiles);
if west_ocean_dist < east_ocean_dist && west_ocean_dist <= 3 {
// West coast: maritime boost
1.0 + MARITIME_TEMP_MODERATION * (1.0 - west_ocean_dist as f32 / 3.0)
} else {
1.0
}
}
```
`MARITIME_TEMP_MODERATION = 0.15` damps the temperature range (reduces `seasonality`) and adds 1020% precipitation. This produces Pacific-NW / British Isles feel without simulating the Gulf Stream.
---
## 7. Elevation lapse rate
Temperature decreases with altitude at approximately 6.5°C per 1 000 m. In the hex elevation scale (0.01.0 normalised, where 1.0 ≈ 5 000 m):
```
fn elevation_temp_offset(elevation: f32) -> f32 {
// 6.5°C / km × 5 km max elevation = 32.5°C at peak
// Normalised to a -1..0 offset in temperature units
-elevation * LAPSE_RATE_COEFFICIENT // default: 0.65
}
```
The lapse rate coefficient (`0.65`) is extracted to `climate.json`.
---
## 8. Derived fields
All four derived fields are computed from the base inputs in a single pass:
```rust
pub struct ClimateCell {
pub latitude: f32, // 1..+1, signed hemispheric
pub continentality: f32, // 0=coastal, 1=deep inland
pub wind_band: WindBand,
pub mean_temp: f32, // Normalised 0..1 (0=coldest, 1=hottest)
pub mean_precip: f32, // Normalised 0..1 (0=driest, 1=wettest)
pub seasonality: f32, // 0=stable, 1=extreme swing
pub aridity_index: f32, // precip / potential_evapotranspiration; <0.5 = arid
}
```
### mean_temp derivation
```
base_temp = 1.0 - abs(latitude) // equator=1, poles=0
temp = base_temp
+ elevation_temp_offset(elevation)
+ west_coast_modifier × MARITIME_TEMP_MODERATION
```
Clamped to 0..1.
### mean_precip derivation
```
base_precip = 0.5 + 0.3 × (1 - abs(latitude)) // tropics wetter
precip = base_precip
× (1 - continentality × 0.4) // inland drier
× rain_shadow_modifier
× west_coast_modifier
```
Clamped to 0..1.
### seasonality derivation
```
seasonality = abs(latitude) × continentality × SEASONALITY_SCALE
```
`SEASONALITY_SCALE = 0.8`. High-latitude continental interiors have extreme seasonality (0.7+); equatorial coasts have near-zero (< 0.05).
### aridity_index derivation
```
potential_et = mean_temp × 0.8 + 0.1 // crude Thornthwaite proxy
aridity_index = mean_precip / potential_et
// < 0.2 = hyper-arid; 0.20.5 = arid; 0.51.0 = semi-arid; > 1.0 = humid
```
---
## 9. T_band / P_band discretisation
For index keying in `TerrainFloraIndex` and `TerrainFaunaIndex` (§ ECOLOGY_BINDING.md), continuous temperature and precipitation are bucketed into 5 discrete bands each:
| Band index | T range (mean_temp) | Label |
|---|---|---|
| 0 | 0.00.20 | `polar` |
| 1 | 0.200.40 | `cold` |
| 2 | 0.400.60 | `temperate` |
| 3 | 0.600.80 | `warm` |
| 4 | 0.801.00 | `hot` |
| Band index | P range (mean_precip) | Label |
|---|---|---|
| 0 | 0.00.15 | `hyper_arid` |
| 1 | 0.150.35 | `arid` |
| 2 | 0.350.60 | `semi_arid` |
| 3 | 0.600.80 | `humid` |
| 4 | 0.801.00 | `wet` |
Band boundaries are extracted to `climate.json`. The 5×5 = 25 possible `(T_band, P_band)` pairs form the key space for the ecology indices.
---
## 10. Whittaker biome boundary table
The Whittaker biome diagram maps `(mean_temp, mean_precip)` to biome. The following table is a discretised approximation using `T_band` and `P_band`:
| T_band | P_band | biome_id |
|---|---|---|
| 0 (polar) | any | `tundra` or `snow` (snow if T<0.10) |
| 1 (cold) | 01 | `tundra` |
| 1 (cold) | 24 | `boreal_forest` |
| 2 (temperate) | 0 | `desert` |
| 2 (temperate) | 1 | `grassland` |
| 2 (temperate) | 23 | `plains` or `forest` (forest if elev<0.5) |
| 2 (temperate) | 4 | `forest` or `swamp` (swamp if riparian_distance=0) |
| 3 (warm) | 0 | `desert` |
| 3 (warm) | 1 | `grassland` |
| 3 (warm) | 2 | `plains` |
| 3 (warm) | 34 | `forest` |
| 4 (hot) | 01 | `desert` |
| 4 (hot) | 2 | `grassland` |
| 4 (hot) | 34 | `jungle` |
| any | any, elev>0.70 | `mountains` (overrides all) |
| any | any, elev>0.85 | `snow` (overrides all) |
Elevation overrides apply last. `biome_id` must match the string identifiers in `public/games/age-of-dwarves/data/terrain/terrains.json`.
---
## 11. Exported TileMeta fields
| Field | Type | Produced by | Consumed by |
|---|---|---|---|
| `latitude` | `f32 (1..+1)` | `climate.rs` | Seasonality, wind band |
| `continentality` | `f32 (0..1)` | `climate.rs` | Temp, precip, aridity |
| `wind_band` | `WindBand` enum | `climate.rs` | Rain shadow, west-coast mod |
| `mean_temp` | `f32 (0..1)` | `climate.rs` | Biome classifier, T_band |
| `mean_precip` | `f32 (0..1)` | `climate.rs` | Biome classifier, P_band |
| `seasonality` | `f32 (0..1)` | `climate.rs` | Fauna domain (future) |
| `aridity_index` | `f32` | `climate.rs` | Biome classifier, overlay |
| `T_band` | `u8 (0..4)` | `climate.rs` | `TerrainFloraIndex` key (p1-48) |
| `P_band` | `u8 (0..4)` | `climate.rs` | `TerrainFloraIndex` key (p1-48) |
| `biome_id` | `String` | `climate.rs` (classifier) | Renderer, ecology indices |
---
## 12. Worked tile examples
### Example A — Maritime temperate (west coast, mid-latitude)
```
Input: row=60/120, latitude=0.0, elevation=0.25, continentality=0.05
west_ocean_dist=1, wind_band=Temperate
Derivation:
base_temp = 1.0 - 0.0 = 1.0 (equatorial... wait latitude=0 for row 60/120)
Actually latitude = 1 - 2×(60/119) ≈ -0.008 ≈ 0.0
base_temp = 1.0 - 0.0 = 1.0 → clamped to realistic: 0.70
elevation_offset = -0.25 × 0.65 = -0.163 → mean_temp ≈ 0.55
maritime_mod: west_coast, dist=1 → +0.10 → mean_temp ≈ 0.60
→ T_band = 3 (warm)
base_precip = 0.5 + 0.3×1.0 = 0.80
continentality_factor = 1 - 0.05×0.4 = 0.98
west_coast precip boost ≈ ×1.15 → mean_precip ≈ 0.90
rain_shadow: not near mountain → ×1.0
→ P_band = 4 (wet)
Biome: T=warm, P=wet → forest
Result: temperate rainforest character (Pacific NW equivalent)
```
### Example B — Continental temperate (inland mid-latitude)
```
Input: latitude=0.10, elevation=0.30, continentality=0.80
wind_band=Temperate, no mountains nearby
Derivation:
base_temp ≈ 0.65, elevation_offset = -0.195 → mean_temp ≈ 0.46
No maritime modifier (far from west coast)
→ T_band = 2 (temperate)
base_precip ≈ 0.58 × (1 - 0.80×0.4) = 0.58 × 0.68 = 0.39
seasonality = 0.10 × 0.80 × 0.8 = 0.064
→ P_band = 2 (semi_arid)
Biome: T=temperate, P=semi_arid → plains
Result: steppe/prairie character (Kansas equivalent)
```
### Example C — Leeward desert (rain shadow, hot latitude)
```
Input: latitude=-0.55, elevation=0.25, continentality=0.60
mountain_proximity=0.80, wind_band=Temperate
position: leeward of major range (upwind side faces ocean)
Derivation:
base_temp = 1 - 0.55 = 0.45 → mean_temp ≈ 0.39 (temperate range)
Wait: abs(latitude)=0.55 → actually warm band input
mean_temp ≈ 0.52 → T_band = 2
base_precip ≈ 0.42
rain_shadow_modifier: leeward, mountain_proximity=0.80
→ 0.4 + (1.0 - 0.4) × (1.0 - 0.80) = 0.4 + 0.12 = 0.52
precip = 0.42 × 0.52 × 0.72 = 0.157
→ P_band = 1 (arid)
Biome: T=temperate, P=arid → grassland (or desert at P_band=0)
Result: Great Basin / Patagonian desert equivalent
```
---
## 13. Backward-compat note (p0-31)
`mc-climate`'s `tile_sync` invariants (objective p0-31) assert that `biome_id` on every `TileState` matches the expected biome for its climate inputs. The new climate fields **widen** those inputs but do not change the output contract: `biome_id` remains a string identifier from `terrains.json`. Existing `tile_sync` assertions continue to hold; the classifier just has more discriminating inputs now.
The previous `cold` proxy (`abs(row/rows - 0.5) * 2 * (1 - climate)`) is superseded by `T_band` derivation. Any code still reading `cold` must be migrated to `mean_temp` or `T_band` at Wave A time.
---
## 14. Non-goals
- **Ocean current simulation** (`g2-05`) — the west-coast maritime asymmetry is a cheap proxy. Full thermohaline circulation is Game 2 scope.
- **Multi-year climate cycles** — seasonality is a single annual amplitude, not a time-series.
- **Day-length variation** — latitude implies day-length but this is not modelled; only temperature and precipitation effects are.
- **Tropical cyclones / weather events** — gameplay events, not worldgen fields.
- **Soil moisture / evapotranspiration full model** — aridity_index uses a crude Thornthwaite proxy sufficient for biome classification.
---
## 15. Open questions
- Should `mean_temp` be expressed in Celsius (or normalised units) for the Whittaker lookup? Currently normalised 0..1 for simplicity; Celsius would enable integration with future temperature-based mechanics.
- The Whittaker table has gaps for edge cases (e.g., hot + polar = impossible but can occur near elevated equatorial hexes). Should these resolve to `desert` or `tundra`?
- Should `wind_band` be stored on TileMeta at all, or computed transiently and discarded after the climate pass?
- Continentality `BFS` uses `is_water(biome_id)` — does this need to be `ocean` only, or should large lakes also reset continentality to 0?
---
## See also
- `WORLDGEN_PIPELINE.md` — stage position and TileMeta data-flow
- `TECTONICS.md` — produces `mountain_proximity` and `boundary_type` consumed by rain-shadow
- `HYDROLOGY.md` — produces `riparian_distance` consumed by biome classifier (swamp override)
- `ECOLOGY_BINDING.md` — consumes `T_band`, `P_band`, `biome_id` for flora/fauna index keying
- `WORLDGEN_RNG.md``SeedDomain::Climate` (climate itself is deterministic; BFS has no randomness)
- `.project/objectives/p2-49-climate-axes-latitude-continentality.md` — implementation acceptance criteria
- `public/games/age-of-dwarves/data/climate.json` — all magic numbers (created at Wave A)
- `public/resources/worlds/earth/climate_params.json` — Earth archetype reference parameters

View file

@ -0,0 +1,349 @@
# Hydrology — Erosion, Flow, Rivers, and Lakes
**Water makes the world legible.** Without hydrology, rivers sit on ridges and lakes appear at random elevations. With this pass, rivers occupy valleys carved by erosion, flow downhill to the sea via a topological DAG, and coalesce into lakes at local minima. The `riparian_distance` field produced here gates aquatic flora and fauna in `p1-48` / `p1-49`.
This is the Wave-B canonical spec for `p1-47`. Hydrology runs after the tectonic prepass and before climate. It consumes `mountain_proximity` from tectonics as a drainage-divide seed.
---
## 1. Algorithm overview
```
TileMeta (post-tectonics: elevation, mountain_proximity)
Hydraulic erosion pre-pass (Mei et al. simplified, 5 sub-iterations)
D6 flow direction assignment (lowest-neighbour with tiebreaker)
Topological sort of hex grid by elevation (low → high)
Drainage area accumulation (reverse topological order)
Planchon-Darboux lake fill (remove flat sinks)
Strahler stream order assignment
Riparian distance BFS from river/lake hexes
Output fields: flow_out, drainage_area, stream_order, lake_id, riparian_distance
```
---
## 2. Hydraulic erosion pre-pass
A single-pass simplified Mei et al. solver (2007, "Fast Hydraulic Erosion Simulation") with ~5 sub-iterations. Purpose: carve valleys so rivers occupy low ground rather than running uphill over ridges.
### Parameters (extracted to `hydrology.json`)
| Parameter | Default | Effect |
|---|---|---|
| `erosion_iterations` | 5 | Sub-iteration count |
| `rain_rate` | 0.01 | Water added per hex per iteration |
| `erosion_coefficient` | 0.03 | Sediment capacity |
| `deposition_coefficient` | 0.02 | Fraction of carried sediment deposited |
| `evaporation_rate` | 0.02 | Water lost per iteration |
### Pseudocode
```
for iter in 0..erosion_iterations:
// Step 1: rain
for each hex h:
water[h] += rain_rate
// Step 2: flow (simple steepest-descent, single step)
for each hex h sorted by elevation descending:
out_dir = lowest_neighbour(h)
if out_dir is None: continue // local minimum / map border
flux = min(water[h], max_flux)
water[h] -= flux
water[neighbour(h, out_dir)] += flux
// Erosion proportional to flux × slope
slope = elevation[h] - elevation[neighbour(h, out_dir)]
capacity = erosion_coefficient * flux * slope
if sediment[h] < capacity:
erode = min(capacity - sediment[h], max_erosion)
elevation[h] -= erode
sediment[h] += erode
else:
deposit = (sediment[h] - capacity) * deposition_coefficient
elevation[h] += deposit
sediment[h] -= deposit
// Step 3: evaporation
for each hex h:
water[h] *= (1 - evaporation_rate)
```
After 5 iterations, valley floors are 515% lower than initial elevation (varies by slope). Ridge elevations are essentially unchanged. This is deliberately low-fidelity — the goal is topological correctness (rivers in valleys), not photorealistic terrain.
---
## 3. D6 flow direction
After erosion, assign a single outflow direction to each non-sink hex.
```
fn assign_flow_direction(hex: HexCoord, elevation: &Grid<f32>) -> Option<HexDir> {
let neighbours = hex_neighbours(hex);
let lowest = neighbours.iter()
.filter(|n| elevation[n] < elevation[hex])
.min_by(|a, b| {
elevation[a].partial_cmp(&elevation[b])
.unwrap_or(Ordering::Equal)
.then_with(|| a.id().cmp(&b.id())) // tiebreaker: lower hex ID
});
lowest.map(|n| direction_to(hex, n))
}
```
**Tiebreaker rule**: when two or more neighbours share the minimum elevation, the one with the lower canonical hex ID wins. This produces deterministic, non-random river branching.
**Map border rule**: hexes at the map boundary are treated as elevation 0 (ocean sink). Any hex adjacent to the border with a downhill path there flows off the map. This prevents rivers from pooling at edges.
---
## 4. Drainage area accumulation
Drainage area = number of hexes whose flow path eventually passes through this hex (including the hex itself).
```
fn compute_drainage_area(flow: &Grid<Option<HexDir>>, elevation: &Grid<f32>) -> Grid<u32> {
// Topological sort: process highest hexes first
let sorted = topological_sort_by_elevation_desc(flow);
let mut area = Grid::fill(1u32); // each hex counts itself
for hex in sorted {
if let Some(dir) = flow[hex] {
let downstream = neighbour(hex, dir);
area[downstream] += area[hex];
}
}
area
}
```
Hexes with `drainage_area >= RIVER_THRESHOLD` (default 12) are considered river hexes and receive a river feature on the hex edge facing downstream.
---
## 5. Planchon-Darboux lake fill
After D6 flow assignment, some hexes are local minima with no outflow neighbour. These become lake basins. The Planchon-Darboux algorithm raises basin floors to the spill point, establishing outflow.
```
fn planchon_darboux_fill(elevation: &mut Grid<f32>, flow: &mut Grid<Option<HexDir>>) {
// Priority queue: process lowest-elevation sinks first
let mut queue = BinaryHeap::new(); // min-heap by elevation
// Seed: all border hexes as open sinks
for hex in border_hexes() {
queue.push((elevation[hex], hex));
}
while let Some((elev, hex)) = queue.pop() {
for neighbour in hex_neighbours(hex) {
if elevation[neighbour] <= elev {
// Neighbour is lower than current — it's already resolved
continue;
}
let new_elev = elev.max(elevation[neighbour]);
elevation[neighbour] = new_elev;
flow[neighbour] = Some(direction_to(neighbour, hex));
queue.push((new_elev, neighbour));
}
}
}
```
After this pass, all hexes have a valid downhill path to the map border or a lake surface. Connected flat-surface areas (all hexes at the same post-fill elevation) are assigned the same `lake_id`.
---
## 6. Strahler stream order
Strahler order measures stream hierarchy. Order 1 = headwater tributary. When two streams of the same order merge, the downstream segment is order + 1. When two streams of different orders merge, the downstream segment is max(order_a, order_b).
```
fn strahler_order(hex: HexCoord, drainage_area: &Grid<u32>, flow: &Grid<Option<HexDir>>) -> u8 {
// Collect all upstream tributaries
let tributaries: Vec<HexCoord> = hex_neighbours(hex)
.iter()
.filter(|n| flow[**n] == Some(direction_to(**n, hex)))
.collect();
if tributaries.is_empty() {
return 1; // headwater
}
let orders: Vec<u8> = tributaries.iter().map(|t| strahler_order(*t, ...)).collect();
let max_order = *orders.iter().max().unwrap();
let max_count = orders.iter().filter(|&&o| o == max_order).count();
if max_count >= 2 { max_order + 1 } else { max_order }
}
```
Strahler order drives river render width: `width = 1 + log2(drainage_area + 1)` pixels at 1:1 zoom.
---
## 7. Coarse-grid resolution strategy (>150×150 maps)
For maps larger than 150×150 hexes (22 500+), the erosion and drainage passes run on a 1/4-resolution coarse grid:
- Downsample: 2×2 hex blocks → 1 coarse hex (average elevation)
- Run all passes on the coarse grid
- Upsample: each coarse hex propagates its flow direction and drainage area to its 4 source hexes, with minor noise perturbation (±0.01 elevation, seeded by `SeedDomain::Hydrology`)
This keeps wall-clock time under 1 second for maps up to 300×300.
---
## 8. Exported TileMeta fields
| Field | Type | Produced by | Consumed by |
|---|---|---|---|
| `flow_out` | `Option<HexDir>` | `erosion.rs` | Internal, debug |
| `drainage_area` | `u32` | `erosion.rs` | River renderer, Strahler |
| `stream_order` | `u8` | `erosion.rs` | River render width |
| `lake_id` | `Option<u16>` | `erosion.rs` | Lake renderer, fauna aquatic gate |
| `riparian_distance` | `u8` | BFS post-hydrology | Flora selection (p1-48), fauna aquatic gate (p1-49) |
### TileMeta struct additions
```rust
pub struct TileMeta {
// ... existing + tectonics fields ...
pub flow_out: Option<HexDir>,
pub drainage_area: u32,
pub stream_order: u8,
pub lake_id: Option<u16>,
pub riparian_distance: u8, // 0 = river/lake hex; 255 = not near water
}
```
### Riparian distance BFS
After all hydrology fields are populated:
```
fn compute_riparian_distance(tiles: &mut Grid<TileMeta>) {
let mut queue = VecDeque::new();
for hex in all_hexes() {
if tiles[hex].drainage_area >= RIVER_THRESHOLD || tiles[hex].lake_id.is_some() {
tiles[hex].riparian_distance = 0;
queue.push_back(hex);
} else {
tiles[hex].riparian_distance = u8::MAX;
}
}
while let Some(hex) = queue.pop_front() {
let current_dist = tiles[hex].riparian_distance;
if current_dist >= MAX_RIPARIAN_DISTANCE { continue; }
for neighbour in hex_neighbours(hex) {
if tiles[neighbour].riparian_distance == u8::MAX {
tiles[neighbour].riparian_distance = current_dist + 1;
queue.push_back(neighbour);
}
}
}
}
```
`MAX_RIPARIAN_DISTANCE = 5` (beyond 5 hexes, riparian effects vanish).
---
## 9. River render contract
The renderer (Godot TileMap or WASM canvas) consumes `stream_order` and `drainage_area`:
- **Path**: bezier curve through the midpoints of consecutive downstream hex edges
- **Width**: `1 + log2(drainage_area + 1)` pixels (clamped to 18)
- **Colour**: blue gradient from `#5B8DD9` (order 1) to `#1A3A6E` (order 7+)
- **Coastline ecotone strokes**: where a river-carrying hex is adjacent to ocean, use the `riverside_forest` / `shore` ecotone from `terrain_blends.json` on that shared edge
Render is presentation-only; the river geometry lives in `flow_out` + `stream_order` fields. Neither Rust nor GDScript duplicates the path computation — Rust produces the fields; the renderer reads them.
---
## 10. Lake render contract
- **Detection**: connected components of hexes sharing the same `lake_id`
- **Fill**: continuous fill using the lake tile type; internal hex borders between same-`lake_id` hexes are suppressed
- **Shore**: the perimeter hex edges of each lake component use the `shore` ecotone from `terrain_blends.json`
- **Size classification**: 1 hex = Pond; 23 hexes = Small Lake; 4+ hexes = Lake; connected to map edge = Ocean/Sea (overrides `lake_id` to `None`, uses ocean tile)
---
## 11. Worked example — 10-cell chain
```
Starting elevation (left → right, all on same row):
H0=0.80 H1=0.75 H2=0.60 H3=0.55 H4=0.40
H5=0.45 H6=0.30 H7=0.25 H8=0.15 H9=0.05
After 5 erosion iterations (valley deepening ~10%):
H0=0.80 H1=0.73 H2=0.56 H3=0.50 H4=0.36
H5=0.40 H6=0.27 H7=0.22 H8=0.13 H9=0.05
D6 flow directions (each → downstream neighbour):
H0→H1 H1→H2 H2→H3 H3→H4 H5→H6 H4→H6 (both drain to H6)
H6→H7 H7→H8 H8→H9 H9→border (sink)
Drainage areas:
H0=1 H1=2 H2=3 H3=4 H5=1 H4=5 H6=7 H7=7 H8=7 H9=7
Strahler orders (H4+H5 merge into H6):
H0..H4 = order 1
H5 = order 1
H6 = order 2 (two order-1 tributaries merge)
H7..H9 = order 2
Riparian distance:
H6,H7,H8,H9 (drainage>=12? No — threshold not met for 10-cell example)
Use threshold=5 for this example: H4,H5,H6,H7,H8,H9 → distance 0
H3 → distance 1; H2 → distance 2; etc.
```
---
## 12. Non-goals
- **Naval pathfinding** (`g6-01`) — hydrology computes topology only; pathfinding over water is a separate objective.
- **Aquifers / groundwater** — Game 2 scope (soil-derivation `g2-06`).
- **Multi-step erosion with sediment transport** — the 5-iteration simplified Mei et al. is intentionally lo-fi. Full particle-tracing erosion is not in scope.
- **Seasonal flow variation** — rivers are steady-state for worldgen purposes.
- **Tidal estuaries** — coast-river interactions are handled by ecotone strokes only.
---
## 13. Open questions
- Should `lake_id` be stable across re-generations with the same seed? Currently yes (Planchon-Darboux is deterministic given elevation), but should it be included in the save format?
- `MAX_RIPARIAN_DISTANCE = 5` — should wetland biome hexes (already classified) force `riparian_distance = 1` even if not adjacent to a drainage river?
- Should the coarse-grid strategy use bilinear upsampling for `drainage_area` or nearest-neighbour? Currently nearest-neighbour for simplicity.
- River `RIVER_THRESHOLD = 12` — does this need to scale with map area, or is it intentionally an absolute value (so large maps have proportionally more rivers)?
---
## See also
- `WORLDGEN_PIPELINE.md` — stage position and data-flow
- `TECTONICS.md` — produces `mountain_proximity` used as drainage-divide seed
- `CLIMATE.md` — consumes `riparian_distance` for aridity and continental moisture
- `ECOLOGY_BINDING.md``riparian_distance` gates aquatic flora and fauna selection
- `WORLDGEN_RNG.md``SeedDomain::Hydrology` and `SeedDomain::Erosion`
- `.project/objectives/p1-47-river-hydrology-network.md` — implementation acceptance criteria
- `public/games/age-of-dwarves/data/hydrology.json` — all tunable parameters (created at Wave B)
- `public/games/age-of-dwarves/data/terrain/terrain_blends.json` — ecotone strokes for river/shore edges

View file

@ -0,0 +1,301 @@
# Tectonics — Voronoi Plate Prepass
**The geological skeleton of every generated world.** Without tectonics, mountain ranges are noise-shaped blobs with no reason to exist where they do. With this prepass, mountains arc along convergent plate boundaries, rifts open along divergent edges, and volcanic arcs offset from ocean trenches — capturing 80% of the visual win in <500 ms on a 200×200 map.
This is the Wave-A canonical spec consumed by `p1-50`. Full multi-step lithospheric simulation (`g2-05-tectonics-lithology`) stays deferred to Game 2.
Every other terraformer doc is downstream of the `mountain_proximity` and `coast_proximity` fields produced here.
---
## 1. Algorithm overview
```
map_seed
seed::derive(map_seed, SeedDomain::Tectonics) → plate_seed
Random K points on hex grid (default K = max(8, floor(area / 400)))
Lloyd relaxation × 3 iterations
Voronoi assignment → every hex gets plate_id
Plate attribute assignment (kind, velocity, age)
Boundary edge classification (Convergent / Divergent / Transform)
Elevation bias: per-boundary-type nudge applied to hex rows within
proximity radius
mountain_proximity, coast_proximity fields populated for every hex
Output: TileMeta fields ready for hydrology + climate consumption
```
---
## 2. Plate generation
### 2.1 Site placement
```
fn generate_plate_sites(seed: u64, hex_count: usize, K: usize) -> Vec<HexCoord> {
let mut rng = Pcg64::seed_from_u64(seed);
let mut sites = Vec::with_capacity(K);
while sites.len() < K {
let coord = random_hex_coord(&mut rng, map_width, map_height);
if sites.iter().all(|s| hex_distance(s, &coord) >= MIN_PLATE_SEPARATION) {
sites.push(coord);
}
}
sites
}
```
`MIN_PLATE_SEPARATION` = max(3, floor(sqrt(hex_count / K))). This prevents plates that share an edge before relaxation.
### 2.2 Lloyd relaxation
Three iterations. Each iteration:
1. Assign every hex to the nearest site by hex distance.
2. Replace each site with the centroid of its assigned hexes.
Centroid in axial coordinates: `(mean(q_i), mean(r_i))` rounded to the nearest hex. Three iterations are sufficient to move elongated plates toward compact shapes without over-regularising.
### 2.3 Final Voronoi assignment
After relaxation, assign each hex to the nearest site. Ties broken by lower plate index. Result: `plate_id: u8` on every hex.
---
## 3. Plate attributes
Each plate receives attributes drawn from the post-relaxation seed:
| Field | Type | Distribution |
|---|---|---|
| `kind` | `Continental \| Oceanic` | 60% Continental, 40% Oceanic (configurable in `tectonics.json`) |
| `velocity` | `HexDir` (0..5) | Uniform random; adjacent plates rarely share direction |
| `age` | `f32 (0..1)` | Beta(2, 2) — most plates are mid-aged |
**Continental plates** carry higher base elevation bias (+0.15). **Oceanic plates** carry lower base elevation bias (0.10). The biases are additive offsets on the fBm noise baseline, not replacements.
---
## 4. Plate-type taxonomy
| Kind | Description | Elevation bias | Notes |
|---|---|---|---|
| `cratonic` | Old, stable continental interior | +0.25 | Produces shield terrain, low relief |
| `passive_margin` | Continental shelf edge, no active boundary | +0.10 | Coastal lowlands |
| `active_margin` | Continental edge at convergent boundary | +0.35 | Source of fold mountain belts |
| `volcanic_arc` | Oceanic plate subducting under continental | +0.20 (arc only) | Offset 24 hexes from trench |
| `rift` | Plate splitting at divergent boundary | 0.15 | Graben valleys, inland seas |
| `hotspot` | Isolated intraplate volcanic point | +0.30 (1-hex radius) | Produces isolated peaks |
Plate kind is assigned during attribute step. `cratonic` / `passive_margin` / `active_margin` / `rift` are derived from the plate's boundary relationships (§5). `volcanic_arc` and `hotspot` are independent random draws (probability in `tectonics.json`).
---
## 5. Boundary classification
For each pair of adjacent plates (sharing at least one hex-edge), compute the **boundary type** from the velocity dot product:
```
fn classify_boundary(a: &Plate, b: &Plate) -> BoundaryType {
// Project velocity directions to unit vectors
let va = hex_dir_to_vec(a.velocity);
let vb = hex_dir_to_vec(b.velocity);
let dot = va.dot(vb);
let cross_component = approach_component(va, vb, boundary_normal(a, b));
match cross_component {
c if c > 0.3 => BoundaryType::Convergent,
c if c < -0.3 => BoundaryType::Divergent,
_ => BoundaryType::Transform,
}
}
```
Where `approach_component` is the component of relative velocity toward the boundary normal (positive = plates approaching each other).
| BoundaryType | Physical meaning | Elevation effect |
|---|---|---|
| `Convergent` | Plates collide; crust thickens | Mountain belt along boundary |
| `Divergent` | Plates pull apart; crust thins | Rift valley / mid-ocean ridge |
| `Transform` | Plates slide past; little vertical motion | Fault scarp, modest offset |
---
## 6. Elevation bias logic
Elevation bias is applied as an additive pass on the existing fBm noise grid. Each hex receives a bias from the nearest plate boundary segment.
```
fn apply_elevation_bias(hex: HexCoord, boundaries: &[BoundarySegment], params: &TectonicsParams) -> f32 {
let nearest = boundaries.iter()
.min_by_key(|b| hex_distance(hex, b.midpoint));
let dist = hex_distance(hex, nearest.midpoint);
if dist > params.influence_radius {
return 0.0;
}
let falloff = 1.0 - (dist as f32 / params.influence_radius as f32);
let base_bias = match nearest.kind {
Convergent => params.convergent_bias, // default +0.40
Divergent => params.divergent_bias, // default -0.25
Transform => params.transform_bias, // default +0.05
};
base_bias * falloff * falloff // quadratic falloff
}
```
`influence_radius` defaults to `ceil(sqrt(area / K) / 2)` — roughly half the average plate radius.
**Mountain placement** along convergent boundaries: hexes within `mountain_threshold` distance (default 2) of a convergent boundary and with `fBm + bias > 0.65` are tagged `mountain_proximity = 1.0`.
**Volcanic arc offset**: for convergent boundaries where one plate is Oceanic, a secondary arc of elevation bias is placed `arc_offset_hexes` (default 3) away from the boundary on the continental side.
---
## 7. Exported TileMeta fields
| Field | Type | Produced by | Consumed by |
|---|---|---|---|
| `plate_id` | `u8` | `tectonics.rs` | Debug/viz only |
| `plate_type` | `PlateKind` enum | `tectonics.rs` | Debug/viz only |
| `boundary_type` | `Option<BoundaryType>` | `tectonics.rs` | Rain-shadow (p2-49) |
| `mountain_proximity` | `f32 (0..1)` | `tectonics.rs` | Rain-shadow (p2-49), hydrology divides (p1-47) |
| `coast_proximity` | `f32 (0..1)` | `tectonics.rs` | Continentality BFS seed (p2-49) |
`coast_proximity` is derived from `plate_type`: hexes on `passive_margin` or `active_margin` plates adjacent to `Oceanic` plates get `coast_proximity = 1.0`; others decay by hex distance from the nearest such hex (capped at `COAST_DECAY_RADIUS = 6`).
### TileMeta struct additions
```rust
pub struct TileMeta {
// ... existing fields ...
pub plate_id: u8,
pub plate_type: PlateKind,
pub boundary_type: Option<BoundaryType>,
pub mountain_proximity: f32, // 0 = far from mountains, 1 = at mountain boundary
pub coast_proximity: f32, // 0 = deep interior, 1 = at coast
}
```
---
## 8. Performance budget
Target: <500 ms on a 200×200 map (40 000 hexes) on a single thread.
| Step | Expected cost |
|---|---|
| Site placement + relaxation (3×) | ~5 ms |
| Voronoi assignment (40 000 hexes) | ~15 ms |
| Boundary classification | ~2 ms |
| Elevation bias pass | ~20 ms |
| Proximity field computation | ~10 ms |
| **Total** | **~52 ms** (well within budget) |
Measurements taken on Apple M2. The budget leaves ample headroom for future parameter tuning.
---
## 9. Parameter extraction
All magic numbers are extracted to `public/games/age-of-dwarves/data/tectonics.json` per Rail 2:
```json
{
"default_plate_count_k": 12,
"area_per_plate": 400,
"lloyd_iterations": 3,
"min_plate_separation": 3,
"continental_fraction": 0.6,
"convergent_bias": 0.40,
"divergent_bias": -0.25,
"transform_bias": 0.05,
"influence_radius_divisor": 2,
"mountain_proximity_threshold": 2,
"arc_offset_hexes": 3,
"coast_decay_radius": 6,
"hotspot_probability": 0.08,
"volcanic_arc_probability": 0.15
}
```
---
## 10. Worked example — 5-plate seed
Seed `0xDEADBEEF_00000001`, map 20×20 (400 hexes), K=5:
```
After Lloyd relaxation, plate sites land approximately at:
P0 (continental, age=0.7, vel=E) → centre-right cluster
P1 (oceanic, age=0.3, vel=W) → left cluster
P2 (continental, age=0.5, vel=NE) → top cluster
P3 (oceanic, age=0.8, vel=SW) → bottom-right
P4 (continental, age=0.4, vel=SE) → bottom-left
Boundary classifications:
P0↔P1: vel_dot = E·W = 1.0 → approach_component > 0.3 → Convergent
P1↔P2: vel_dot = W·NE ≈ 0.5 → approach_component > 0.3 → Convergent
P2↔P3: vel_dot = NE·SW = 1.0 → Convergent
P3↔P4: vel_dot = SW·SE ≈ 0.5 → approach_component near 0 → Transform
P4↔P0: vel_dot = SE·E ≈ 0.7 → approach_component < 0.3 → Divergent
Elevation bias result:
P0↔P1 convergent: mountain belt at column ~10, rows 416
P1↔P2 convergent: mountain arc at top-left corner
P3↔P4 transform: modest fault scarp at bottom
P4↔P0 divergent: rift valley at right edge (base elev 0.25)
mountain_proximity = 1.0 for hexes within 2 of the P0↔P1 belt
coast_proximity derived from passive_margin hexes adjacent to P1/P3
```
This seed produces a world with a major central mountain range dividing east from west — the shape that drives rain-shadow asymmetry in `p2-49`.
---
## 11. Non-goals
- **Real-time tectonic motion** (`g2-05-tectonics-lithology`) — deferred to Game 2. This is a one-shot prepass, not a simulation.
- **Lithology / rock type derivation** (`g2-05`) — Game 2 scope.
- **Earthquake / volcanic eruption events** — gameplay events, not worldgen prepass.
- **Sub-hex plate resolution** — plates are hex-grain, not sub-tile.
- **Ocean current simulation** — approximated as west-coast asymmetry in `p2-49`; full current sim is Game 2.
---
## 12. Open questions
- Should `hotspot` plates produce a chain of decreasing-age peaks (simulating plate motion over the hotspot)? Currently a single peak.
- `arc_offset_hexes = 3` is a fixed integer. Should it scale with map size?
- Transform boundaries: should they produce a modest lateral terrain offset (staggered ridge) or remain nearly flat?
- Should `coast_proximity` be computed here or in the hydrology pass after water body identification? Current decision: here, from plate geometry, so hydrology can use it as a drainage-basin seed.
---
## See also
- `WORLDGEN_PIPELINE.md` — stage position in the full pipeline and TileMeta data-flow
- `HYDROLOGY.md` — consumes `mountain_proximity` for drainage divide seeding
- `CLIMATE.md` — consumes `boundary_type` + `mountain_proximity` for rain-shadow
- `WORLDGEN_RNG.md``SeedDomain::Tectonics`, PCG64 pin
- `.project/objectives/p1-50-tectonic-prepass.md` — implementation acceptance criteria
- `public/games/age-of-dwarves/data/tectonics.json` — all tunable parameters (created at Wave A)