Add Biota stage: flora, fauna & funga (density + slot/point population)

World-Creation stage after biomes. Two layers (raylib-free engine):
- Per-cell density scalars (flora/fauna/funga in [0,1]) derived from the
  climate fields each tick (drive color modes 8/9/0). Flora = Liebig-min of
  temp & moisture; fauna ~ flora with carnivores gated on local prey; funga =
  moisture/organic-matter-led + cold-tolerant. Zero on water/ice.
- On-demand discrete population (key L, saved as v7): each land cell draws
  broad archetypes from a comprehensive table into a per-kind slot cap +
  density-scaled point budget (size -> cost), weighted by biome/climate
  suitability and a regional bonus for same-biome neighbours. Separate RNG
  seeded from cfg.seed so generating biota never perturbs tectonic determinism.

Organisms are labelled by taxonomy (Family + Size + role, e.g. "Felidae
(Big, Carnivore)") with the full Class > Order > Family tree stored, never an
informal common name. Cell-info panel word-wraps + aggregates duplicates so the
lists no longer get cut off.

New: src/sim/PlanetBiota.{hpp,cpp} + PlanetFlora/Fauna/FungiGen.cpp, color
modes/colors, bio* config knobs, save v7 (older saves load with empty
population), test_biota.cpp (densities, fauna<=capacity, carnivore gating,
slot/point budgets, determinism + RNG isolation, v7 round-trip). Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-25 13:56:03 +02:00
parent acc0e5eec9
commit 53e371bb2f
24 changed files with 1498 additions and 101 deletions

View File

@ -3,7 +3,8 @@
Fixed icosphere geometry; properties (elevation, plate, age, climate, biome) flow
over it. World creation runs as continuous, overlapping stages on a geological clock:
tectonics (boundary stress forms mountains/trenches) → continental drift & erosion →
hydrology (rivers/lakes) → climate (temperature/precipitation) → biomes. Initial
hydrology (rivers/lakes) → climate (temperature/precipitation) → biomes → biota
(flora/fauna/funga). Initial
terrain forming is a generator (a couple hundred paced ticks, ~3 s, auto-pauses at
equilibrium), then drift/erosion/etc. continue. Press R to reseed, SPACE to pause.
(The eventual goal is a separate slow real-time "Live World" weather/life mode.)
@ -39,11 +40,13 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
click open tile detail panel (subtiles grid, hoverable) + overlay
C close the detail panel
1 .. 7 color by elevation / plate / age / crust type / biome / temperature / precipitation
8 / 9 / 0 color by biota density: flora / fauna / funga
B toggle plate borders (on by default)
D toggle per-plate drift arrows + P<id> labels (on by default)
G toggle lat/lon graticule (+ degree numbers on the 2D map edges)
J toggle rivers (Phase 2.5 hydrology)
H start/stop Phase 2.5 (hydrology: rivers, lakes, fluvial erosion)
L generate biota population (flora/fauna/funga; settled world; re-press regenerates)
SPACE pause while forming / re-evolve once settled (or the on-screen button)
[ / ] drift speed (My per real second, Phase 2/3)
S single tectonic tick
@ -147,14 +150,38 @@ shadow, dry interiors) then diffuses it. Color modes 6 (temperature) / 7 (precip
climateWindPasses 50 moisture-advection iterations (steady state)
climateMoistureSmooth 12 precipitation diffusion passes (raise = smoother, more grass/forest)
Biota (PlanetConfig): flora/fauna/funga. Density scalars drive color modes 8/9/0;
the discrete slot/point population is generated on demand (L) and saved (v7).
bioVegTempMin -5 C below this no plant growth
bioVegTempOpt 15 C at/above this temperature isn't limiting (flora)
bioVegMoistRef 0.5 normalized moisture where water isn't limiting (flora)
bioFaunaProductivity 0.9 herbivore carrying capacity per unit vegetation
bioCarnPreyMin 0.30 min local prey (fauna density) to support carnivores
bioCarnScale 1.0 carnivore weight ramp above the prey threshold
bioFungaMoistRef 0.4 normalized moisture where fungi aren't water-limited
bioFungaFloraWeight 0.6 how much fungi lean on flora (organic matter), 0..1
bioFungaTempMin -15 C above this fungi are not cold-limited (cold-tolerant)
bioRegionBonus 0.5 weight boost for archetypes present in same-biome neighbours
bioFloraSlots 12 max distinct flora per cell (point budget caps abundance)
bioFaunaSlots 10 max distinct fauna per cell
bioFungaSlots 8 max distinct funga per cell
bioFloraPoints 20 flora point budget at full density (scaled by density)
bioFaunaPoints 16 fauna point budget at full density
bioFungaPoints 14 funga point budget at full density
## Headless logic test (no display)
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetIO.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t
# Biota suite: same source list, swap test_logic.cpp -> test_biota.cpp
Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step().

View File

@ -40,10 +40,14 @@ dynamic weather and life.
updates as terrain changes). Color modes `6`/`7`.
- **Biomes** *(done)* — per-cell `Cell.biome` (13 biomes incl. polar Ice) from elevation
+ the climate fields (`Planet::classifyBiomes`), color mode `5`, saved per cell.
- **Fauna & flora** *(next, planned — see `docs/fauna-flora-plan.md`)* — derived
carrying-capacity densities: vegetation (flora) + a herbivore/carnivore food chain (fauna),
computed from the climate fields each tick (the living/evolving ecosystem is reserved for
Live World). Other follow-ups: feed precipitation into hydrology rainfall; seasons (obliquity).
- **Biota — flora, fauna & funga** *(done — see `docs/fauna-flora-plan.md` +
`docs/fauna_generation_plan.md`)* — two layers: per-cell **density scalars** (flora,
fauna, funga ∈ [0,1]) derived from the climate fields each tick (drive the colour views),
plus a discrete **slot/point population** of broad archetypes (Class/Order/Family/Size),
generated **on demand** (`L`) and **saved** (save v7). Fauna is a herbivore/carnivore/
omnivore food chain (predators gated on local prey); funga uses a flora-like but
moisture/organic-matter-led rule. The living/evolving ecosystem is reserved for Live World.
Other follow-ups: feed precipitation into hydrology rainfall; seasons (obliquity).
> Durable design context (module layout, save format, climate/biome model, conventions)
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
@ -256,6 +260,28 @@ Working and verified (logic tested headless):
updating with `1``7`. The HUD title/status and the hydrology prompt were reworded to drop
the rigid "Phase N" labels (now "World Creation: forming / drift & erosion / hydrology");
internal `phase*` names are unchanged. Render/text only — no sim/save/config change.
- **Biota (flora/fauna/funga):** the World-Creation stage after biomes. Two layers (src/sim,
raylib-free): (1) **density scalars** `sFloraDensity`/`sFaunaDensity`/`sFungaDensity` ∈ [0,1]
via `Planet::computeBiotaDensity()` — flora = NPP Liebig-min of temp & moisture (0 on
water/Ice), fauna = herbivore capacity ∝ flora with carnivores gated on local prey
(`bioCarnPreyMin`), funga = flora-like but moisture/organic-matter-led + cold-tolerant.
Derived each tick (like climate), drive color modes `8`/`9`/`0`. (2) A discrete
**slot/point population** `Planet::generateBiota()` (key `L`, on a settled world) — each land
cell draws broad **archetypes** from a comprehensive table (`biotaArchetypes()`, 36 entries
across Flora/Fauna/Funga, each with Class/Order/Family/Size + a biome mask + climate
tolerance) into a per-kind slot cap + a density-scaled point budget (Tiny=1…Huge=5 cost),
weighted by suitability and a **regional bonus** for archetypes already placed in same-biome
neighbours (homogeneous regions, variety at boundaries). Organisms are labelled by their
**taxonomy** — Family + Size + role (e.g. *Felidae (Big, Carnivore)*, with the full
*Class > Order > Family* tree in `organismTaxonomy()`), never an informal common name like
"big cat"; generalist families get a biome adjective (*Desert Muridae*). Uses a **separate
RNG** seeded from `cfg.seed` so generating biota never
perturbs tectonic determinism. Population is **saved** (`sBiota`, save **v7**); densities are
derived/not-saved. New files `PlanetBiota.{hpp,cpp}` + `PlanetFlora/Fauna/FungiGen.cpp`;
color modes `floraColor`/`faunaColor`/`fungaColor`; cell-info shows density % + the per-kind
organism list. `bio*` config knobs. Headless `test_biota.cpp`: density ranges/zeros, fauna≤
capacity, carnivore gating, slot/point budgets, determinism + RNG isolation, v7 round-trip,
pre-v7 loads empty. v7 reads v6-and-older (no biota block → empty population; press `L`).
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
right-side detail panel: tile info header + the tile's subgrid drawn as a
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
@ -286,9 +312,16 @@ src/
PlanetDrift.cpp cflDtMy/advect + plate lifecycle (fission/kick/baby/fuse)
PlanetErosion.cpp erode() + adjustSeaLevel()
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
PlanetClimate.cpp computeClimate() (temperature + orographic precipitation)
PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity
PlanetFloraGen.cpp computeFloraDensity + fillFlora
PlanetFaunaGen.cpp computeFaunaDensity + fillFauna (carnivores gated on prey)
PlanetFungiGen.cpp computeFungaDensity + fillFunga (moisture/organic-matter rule)
PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer)
Colors.* cell color modes (elevation/plate/age/crust/lake)
Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota)
Map2D.* Equal Earth 2D map: positions + projection/draw helpers
Overlays.* borders, drift arrows, rivers, graticule, segments, subgrids
Picking.* mouse ray / sphere hit / nearest-cell / angle helpers
@ -344,9 +377,12 @@ raylib 5.5 is fetched automatically — do not vendor it.
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetIO.cpp \
src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp \
-o /tmp/t && /tmp/t
```
(Swap `test_logic.cpp` for `test_biota.cpp` to run the Biota suite — same source list.)
Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without
@ -371,13 +407,14 @@ compute-shader port -- a Phase-2 effort.
LMB drag orbit · wheel zoom · hover for cell info (3D or map) ·
click a tile to open its detail panel (subtiles) · `C` close panel ·
drag the 2D map to pan it east/west · `1`..`7` color by
elevation/plate/age/crust-type/biome/temperature/precipitation (active mode shown
top-center of the globe) ·
drag the 2D map to pan it east/west · `1`..`0` color by
elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga
(`8`/`9`/`0` = biota density; active mode shown top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `SPACE` or on-screen button pause · `[`/`]` drift speed (My/sec) ·
`S` single tick · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `R` reseed ·
`H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga,
on a settled world; re-press regenerates) · `R` reseed ·
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
`F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate.
@ -395,9 +432,11 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **6**; v2 adds the `[`/`]` drift rate, v3 a
save header is versioned (currently **7**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump); newer-than-supported is rejected.
key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte); newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`).
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves —
@ -454,6 +493,15 @@ triangles (plates are fixed in phase 1).
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
`climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params.
- Biota (`bio*` in PlanetConfig / `planet.cfg`) — density: `bioVegTempMin`/`bioVegTempOpt`/
`bioVegMoistRef` (flora temp/moisture limits), `bioFaunaProductivity` (animals per unit
flora), `bioCarnPreyMin`/`bioCarnScale` (carnivore prey gate + ramp), `bioFungaMoistRef`/
`bioFungaFloraWeight`/`bioFungaTempMin` (funga moisture/organic-matter/cold rules);
slot/point population: `bioFloraSlots`/`bioFaunaSlots`/`bioFungaSlots` (distinct-type cap),
`bioFloraPoints`/`bioFaunaPoints`/`bioFungaPoints` (point budget at full density, scaled by
it; Tiny=1…Huge=5), `bioRegionBonus` (how strongly a cell copies same-biome neighbours →
homogeneity vs variety). To add organisms, append to `biotaArchetypes()` in PlanetBiota.cpp
(append-only — indices are serialized in v7 saves).
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -26,6 +26,10 @@ add_executable(planetsim
src/sim/PlanetHydrology.cpp
src/sim/PlanetBiomes.cpp
src/sim/PlanetClimate.cpp
src/sim/PlanetBiota.cpp
src/sim/PlanetFloraGen.cpp
src/sim/PlanetFaunaGen.cpp
src/sim/PlanetFungiGen.cpp
src/sim/PlanetIO.cpp
# Viewer (raylib) -- src/render
src/render/Colors.cpp

View File

@ -32,15 +32,19 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
steepest-descent→rivers, mass-conserving stream-power incision).
- `PlanetClimate.cpp``computeClimate()` (temperature + orographic precipitation).
- `PlanetBiomes.cpp``classifyBiomes()` (per-cell `Cell.biome` from elevation + climate).
- `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw +
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
- `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density +
per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led).
- `PlanetIO.cpp` — text config + binary save/load.
- (`PlanetBiosphere.cpp` — fauna/flora, planned; see `fauna-flora-plan.md`.)
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
`ViewerInput.cpp` (camera/picking/keys), `ViewerRender.cpp` (globe/map/panels/HUD/prompt),
plus topical helpers `Colors` / `Map2D` / `Overlays` / `Picking` / `Panels`.
Per-tick order in `Viewer::refreshView()`: `computeHydrology()` (if hydrology on) →
`computeClimate()``classifyBiomes()` → (`computeBiosphere()` when added) → `recolor()`.
`computeClimate()``classifyBiomes()``computeBiotaDensity()``recolor()`. The discrete
biota *population* (`generateBiota()`) is NOT in this per-tick path — it's on-demand (key `L`).
## Core principle (do not violate)
@ -56,14 +60,33 @@ between world and model space must compensate with `rotateZ(v, ±tilt)` (src/ren
Picking.cpp): 3D picking un-rotates the ray hit by `tilt` before `nearestCell`; 3D plate
labels rotate by `+tilt` before projecting. The 2D map + biome/climate are tilt-independent.
## Save format (v6) — self-describing config
## Save format (v7) — self-describing config + biota population
`planet.save` stores `PlanetConfig` as a **self-describing key=value text block** (not a raw
POD dump), parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared in
PlanetIO.cpp), written at `precision(17)` so doubles round-trip exactly. Consequence:
**adding/removing PlanetConfig fields no longer breaks saves** (unknown keys ignored, missing
keys keep defaults). v6 cannot load pre-v6 saves (one-time break; a length guard fails it
gracefully). Per-cell `Cell.biome` is saved (a byte appended after `invader`).
gracefully). Per-cell `Cell.biome` is saved (a byte appended after `invader`). **v7** appends
the **biota population** (`sBiota`): a flag byte, then three `Organism{uint16 archetype, uint8
biome}` lists per cell. Densities are derived (not saved). Older saves without the block load
fine with an empty population (`readState(is, hasBiome, hasBiota)`; `hasBiota = ver>=7`).
## Biota (flora / fauna / funga) — density + slot/point population
Two layers (`PlanetBiota.cpp` + the three `*Gen.cpp`): (1) derived per-cell **density** scalars
(0..1) recomputed each tick like climate — flora = Liebig-min(temp, moisture), fauna ∝ flora
(carnivores gated on neighbourhood prey ≥ `bioCarnPreyMin`), funga = moisture/organic-matter-led
+ cold-tolerant; 0 on water/Ice. (2) On-demand discrete **population** `generateBiota()`: each
land cell draws broad archetypes from the comprehensive append-only `biotaArchetypes()` table
into a per-kind slot cap + density-scaled point budget (size → cost Tiny=1…Huge=5), weighted by
biome/climate suitability and a **regional bonus** for archetypes already in same-biome
neighbours (single index-ordered pass → homogeneous regions, boundary variety). Organisms are
labelled by **taxonomy** — Family + Size + role (full `Class > Order > Family` in
`organismTaxonomy()`), never informal common names ("Felidae", not "cat"); generalist families
get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seeded from `cfg.seed`** (not
`Planet::rngState`) so populating biota never perturbs tectonic determinism — asserted in
`test_biota.cpp`. The archetype table is **append-only** (indices are serialized in v7 saves).
## Climate + biome model (derived, not saved)
@ -88,7 +111,9 @@ Engine is raylib-free, so logic is tested without a display. Build/run:
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \
src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota.cpp uses the same source list (Biota suite).
```
(add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are
data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).

View File

@ -1,82 +1,56 @@
# Next step — Fauna & Flora (derived carrying-capacity densities)
# Biota — Flora · Fauna · Funga (density + slot/point population)
> Status: **planned, not yet implemented.** This is the next World-Creation stage after
> climate. Approved design captured here so it can be picked up verbatim (e.g. after the
> project moves to another machine — the auto-memory does not travel, this doc does).
> Status: **implemented.** This supersedes the original density-only sketch. The discrete
> slot/point design comes from `docs/fauna_generation_plan.md`; this doc records the unified,
> shipped design. Code: `src/sim/PlanetBiota.{hpp,cpp}` + `PlanetFloraGen/FaunaGen/FungiGen.cpp`;
> test `test_biota.cpp`; controls `8`/`9`/`0` (density views) + `L` (generate population).
## Decision summary
## Three kinds (Biota)
- **Derived carrying-capacity map**, not a live simulation: vegetation + wildlife
**density scalars** recomputed each tick from the climate fields + terrain, exactly like
biomes/climate. It answers *"what could live here"*, not *"what is living/evolving here"*.
- The actual **time-evolving ecosystem** (population growth, migration, predator-prey,
extinction, seasons, species) is reserved for the future **Live World** real-time mode
(year/season timescales — not the My geological clock).
- Fauna uses a **herbivore + carnivore** food-chain split.
- Fields are **derived / not saved** (deterministic functions of climate) → **no
save-format change**. New config knobs ride the v6 self-describing save with no break.
Living things are split into **Flora** (plants), **Fauna** (animals) and **Funga** (fungi).
Funga is *not* a plant but is generated with a flora-like environmental system and its own
rules; all three share the same per-cell slot/point logic.
## Part A — Biosphere fields (`src/sim/`, raylib-free)
## Two layers
- **`Planet.hpp`**: declare `void computeBiosphere();` + accessors
`const std::vector<double>& vegetation()/herbivores()/carnivores() const`. Add scratch
members `std::vector<double> sVeg, sHerb, sCarn;` (not saved).
- **New `src/sim/PlanetBiosphere.cpp`**`Planet::computeBiosphere()` (run *after*
`computeClimate()`; reads `sTemp` + `sMoist`):
- **Vegetation (flora)** `sVeg[i] ∈ [0,1]`: `0` on water (`elev ≤ seaLevel`). On land, an
NPP-style **Liebig minimum** of a temperature factor and a moisture factor:
- `tF = clamp((sTemp[i] bioVegTempMin)/(bioVegTempOpt bioVegTempMin), 0, 1)`
- `mF = clamp(sMoist[i]/bioVegMoistRef, 0, 1)`
- `sVeg = min(tF, mF)` → lush warm-wet, ~0 in ice/desert/alpine (cold- or water-limited),
matching the biomes.
- **Herbivores** `sHerb[i] = clamp(sVeg[i] · bioHerbProductivity, 0, 1)` (capacity scales
with plant productivity).
- **Carnivores** `sCarn[i]`: present only where prey is abundant, ramping with it:
`sCarn = sHerb > bioCarnPreyMin ? clamp((sHerb bioCarnPreyMin)/(1 bioCarnPreyMin)
· bioCarnScale, 0, 1) : 0` → concentrated in the richest regions (an energy-pyramid feel,
visually distinct from the broader herbivore pattern).
- **`src/sim/Planet.cpp` `generate()`**: call `computeBiosphere()` after `classifyBiomes()`.
1. **Density scalars** (`sFloraDensity`/`sFaunaDensity`/`sFungaDensity` ∈ [0,1]) — derived from
the climate fields each tick (like biomes/climate), drive the colour views. 0 on water/Ice.
- Flora = NPP-style **Liebig minimum** of a temperature factor
`clamp((TbioVegTempMin)/(bioVegTempOptbioVegTempMin),0,1)` and a moisture factor
`clamp(sMoist/bioVegMoistRef,0,1)`.
- Fauna = herbivore carrying capacity `clamp(floraDensity·bioFaunaProductivity,0,1)`;
carnivores in the *population* are gated on neighbourhood prey ≥ `bioCarnPreyMin`.
- Funga = `min(moistFactor, bioFungaFloraWeight·floraDensity + (1w)) · coldTolerance`
(`bioFungaMoistRef`, `bioFungaTempMin`) — thrives moist + with organic matter, persists in
cold shade where flora thins, ~0 in hot dry desert.
2. **Discrete slot/point population** (`sBiota`, saved v7) — generated **on demand**
(`generateBiota()`, key `L`, settled world). Each land cell draws broad **archetypes** (NOT
real species) from the comprehensive append-only table `biotaArchetypes()` — each carries a
Class/Order/Family + Size (Tiny..Huge), a biome mask and a climate tolerance. A cell has a
per-kind **slot** cap (distinct types) and a density-scaled **point** budget (size → cost
Tiny=1…Huge=5); archetypes are added until slots full or points exhausted. Selection is
weighted by biome/climate suitability × a **regional bonus** for archetypes already placed in
already-filled, same-biome neighbours (homogeneous regions, variety at boundaries). Organisms
are **named by taxonomy** — Family + Size + role (full `Class > Order > Family` available),
never an informal common name ("Felidae", not "cat"; Felidae is the family, cat a common
name). Generalist families get a biome adjective ("Desert Muridae" / "Forest Muridae").
## Part B — Config knobs (`PlanetTypes.hpp` + `PlanetIO.cpp`)
## Config knobs (`bio*`, in `planet.cfg`)
Add to `PlanetConfig` + `CONFIG_FIELDS` + `validateConfig`:
`bioVegTempMin(-5)`, `bioVegTempOpt(15)`, `bioVegMoistRef(0.5)`, `bioFaunaProductivity(0.9)`,
`bioCarnPreyMin(0.30)`, `bioCarnScale(1.0)`, `bioFungaMoistRef(0.4)`, `bioFungaFloraWeight(0.6)`,
`bioFungaTempMin(-15)`, `bioRegionBonus(0.5)`, slot caps `bioFlora/Fauna/FungaSlots(12/10/8)`,
point budgets `bioFlora/Fauna/FungaPoints(20/16/14)`.
| key | default | meaning |
|---|---|---|
| `bioVegTempMin` | 5 °C | below this, no plant growth |
| `bioVegTempOpt` | 15 °C | at/above this, temperature isn't limiting |
| `bioVegMoistRef` | 0.5 | normalized moisture at which water isn't limiting |
| `bioHerbProductivity` | 0.9 | herbivore capacity per unit vegetation |
| `bioCarnPreyMin` | 0.30 | min herbivore density to support carnivores |
| `bioCarnScale` | 1.0 | carnivore density ramp above the prey threshold |
## Determinism & persistence
## Part C — Rendering (`src/render/`)
- **`Colors.{hpp,cpp}`**: `ColorMode` += `Vegetation`, `Herbivores`, `Carnivores`; add
`vegColor` (barren tan → lush green), `herbColor` (pale → amber/orange), `carnColor`
(pale → red/violet); extend `colorModeName`.
- **`Viewer.cpp`**: `recolor()` adds the three cases (read the accessors, empty-guarded);
`refreshView()` calls `planet.computeBiosphere()` after `classifyBiomes()`.
- **`ViewerInput.cpp`**: `KEY_EIGHT` → Vegetation, `KEY_NINE` → Herbivores, `KEY_ZERO`
Carnivores (each `recolor()`). The top-center view-name label already shows the mode.
- **`ViewerRender.cpp` `renderHUD`**: controls line gains `8 veg 9 herbiv 0 carniv`.
- **`Panels.cpp` `cellInfo()`**: add a life line — `vegetation X% herbivores Y%
carnivores Z%` (guarded on the arrays being sized).
## Verification (headless — extend the `src/sim` test set with `PlanetBiosphere.cpp`)
- **Vegetation:** 0 on ocean & Ice cells; near-0 on Desert; high (>0.6) on warm-wet Forest;
all in [0,1]; finite; deterministic.
- **Fauna food chain:** `sHerb` correlates with `sVeg`; `sCarn[i] > 0 ⇒ sHerb[i] >
bioCarnPreyMin` and carnivore-positive cells are a strict subset of herbivore-rich cells;
all in [0,1]; deterministic.
- `test_logic`, biomes+config, climate suites still pass; app builds clean; no save change.
- **GUI:** `8` lush green continents thinning to barren in deserts/poles/peaks; `9` grazer
density following productivity; `0` predators concentrated in the richest belts; cell info
shows vegetation/herbivore/carnivore %.
`generateBiota()` uses a **separate RNG seeded from `cfg.seed`** (not `Planet::rngState`), so it
never perturbs tectonic determinism (asserted in `test_biota.cpp`). The population is **saved**
(save **v7**); densities are derived/not-saved. Older saves load with an empty population (press
`L`). The archetype table is **append-only** — indices are serialized.
## Out of scope (future / Live World)
Time-evolving populations (growth, migration, predator-prey, extinction), seasons, and
species/typed organisms — all part of the future **Live World** real-time simulation that
runs the finished planet at hours/days/weeks/months with dynamic weather + life.
Time-evolving populations (growth, migration, predator-prey dynamics, extinction), seasons, and
true species/typed organisms — reserved for the **Live World** real-time mode. This stage
answers *"what could live here"*, not *"what is living/evolving here"*.

View File

@ -0,0 +1,91 @@
Fauna Generation Plan
For fauna, I want the system to handle animals in broad ecological groups: predators, herbivores, and animals that fall somewhere in between.
1. Basic Animal Types
First, we should define basic animal types that can exist in many different environments. These are not specific species, but general animal categories.
For example:
Rodents
Desert rodents in desert regions
Swamp rodents in swamp regions
Forest rodents in forest regions
The same approach should be used for other common animal groups that can appear across many biomes.
2. Animal Classification
For regional animals, we should describe them more by broad biological classification and size rather than by exact genus or species.
The classification should use something like:
Class
Order
Family
Size category
Example:
text
Animal: Cat-like predator
Class: Mammal
Order: Carnivora
Family: Felidae
Size: Small
A tiger-like animal would use the same family but have a larger size:
text
Animal: Tiger-like predator
Class: Mammal
Order: Carnivora
Family: Felidae
Size: Big
Size categories could be:
Tiny
Small
Medium
Big
Huge
If more biological information is needed, please ask me. You can also look up general classification information if needed.
3. Cell Population System
Each map cell should be populated using a slot and point system.
For example:
Each cell has 10 fauna slots
Each cell has 20 fauna points
Every animal takes up a certain number of points depending on its size:
Size Point Cost
Tiny 1
Small 2
Medium 3
Big 4
Huge 5
Animals are added to the cell until either:
All slots are filled, or
All points are used
Once one of these limits is reached, the system stops adding animals to that cell.
4. Regional Distribution
To create a more homogeneous and natural distribution, the system should check neighboring cells when populating fauna.
The rules could be:
If neighboring cells have the same climate, there is a high probability that the same or similar animals appear there.
If neighboring cells have a different climate, it is more likely animals should be generated instead.
Similar biomes should share more fauna.
Very different biomes should have more distinct fauna.
This should help avoid every cell feeling completely random while still allowing variety between different regions.
5. Overall Goal
The goal is to create a fauna system that feels natural, biome-based, and regionally consistent, without needing to define every animal as an exact real-world species. Animals should be generated from broad biological groups, ecological roles, and size categories.

View File

@ -0,0 +1,368 @@
Flora Generation Plan
For flora, I want the system to generate plant life based on biome, climate, terrain, water availability, and regional consistency. Like fauna, flora should not always be exact real-world species, but broader plant types that fit naturally into the environment.
1. Basic Plant Types
First, we should define basic plant categories that can appear across many environments, with variations depending on biome and climate.
For example:
Grasses
Dry grass in savannas
Marsh grass in wetlands
Alpine grass in mountains
Shrubs
Desert shrubs
Thorny shrubs
Berry bushes
Trees
Tropical trees
Conifer trees
Deciduous trees
Mangrove trees
Fungi
Forest mushrooms
Swamp fungi
Cave fungi
These should work similarly to the “basic animal types” from the fauna system. A plant type can exist in many places, but its exact form changes depending on the environment.
2. Flora Classification
Flora should be described using broad plant groups rather than exact species.
Possible classification fields:
Plant group
Growth form
Size category
Climate preference
Water requirement
Terrain preference
Ecological role
Example:
text
Plant: Desert shrub
Group: Angiosperm
Growth Form: Shrub
Size: Small
Climate: Arid / Hot
Water Requirement: Low
Terrain: Sandy / Rocky
Role: Ground cover / Food source
Another example:
text
Plant: Giant rainforest tree
Group: Angiosperm
Growth Form: Tree
Size: Huge
Climate: Tropical / Humid
Water Requirement: High
Terrain: Soil-rich lowland
Role: Canopy / Habitat
Possible growth forms:
Grass
Moss
Fern
Shrub
Bush
Tree
Vine
Reed
Cactus / succulent
Fungus
Aquatic plant
Possible size categories:
Tiny
Small
Medium
Big
Huge
3. Biome-Based Flora
Each biome should have a set of likely plant types.
Examples:
Desert
Likely flora:
Cacti / succulents
Dry shrubs
Thorn bushes
Sparse grasses
Drought-resistant flowers
Rare flora:
Oasis trees
Reeds near water
Desert fungi after rain
Forest
Likely flora:
Deciduous trees
Conifer trees
Ferns
Moss
Mushrooms
Berry bushes
Rare flora:
Ancient giant trees
Poisonous flowers
Medicinal herbs
Swamp / Wetland
Likely flora:
Reeds
Mangroves
Water lilies
Moss
Wetland grasses
Fungi
Rare flora:
Carnivorous plants
Giant swamp trees
Rare medicinal roots
Grassland / Steppe
Likely flora:
Grasses
Wildflowers
Small shrubs
Herbs
Rare flora:
Lone trees
Thorn bushes
Seasonal flowers
Mountain / Alpine
Likely flora:
Alpine grass
Lichens
Moss
Small shrubs
Hardy flowers
Rare flora:
Ancient mountain trees
Rare herbs
Snow-resistant plants
Tundra
Likely flora:
Moss
Lichens
Low shrubs
Cold-resistant grasses
Rare flora:
Seasonal flowers
Fungi
Hardy berry bushes
4. Cell Population System
Flora can also use a slot and point system, similar to fauna.
For example:
Each cell has 15 flora slots
Each cell has 30 flora points
Plants are added until either the slots or points are filled.
Suggested point cost by size:
Size Point Cost
Tiny 1
Small 2
Medium 3
Big 5
Huge 8
Flora may need more slots than fauna because plants are usually more numerous and layered.
A cell could contain:
text
Cell Flora:
- Forest floor moss, Tiny, 1 point
- Ferns, Small, 2 points
- Berry bushes, Small, 2 points
- Deciduous trees, Big, 5 points
- Ancient oak-like trees, Huge, 8 points
The system stops adding plants once either:
All flora slots are filled, or
All flora points are used
5. Flora Layers
To make flora feel more natural, each cell can have vegetation layers.
Possible layers:
Ground layer
Herb layer
Shrub layer
Understory layer
Canopy layer
Aquatic layer
Not every biome needs every layer.
Example for a forest:
text
Ground Layer: Moss, fungi
Herb Layer: Ferns, flowers
Shrub Layer: Berry bushes
Understory Layer: Young trees
Canopy Layer: Large trees
Example for a desert:
text
Ground Layer: Dry grass, small succulents
Shrub Layer: Thorn bushes
Tree Layer: Rare oasis trees
This helps prevent weird combinations, like a dense rainforest canopy appearing in a dry desert cell.
6. Climate and Terrain Rules
Flora should be strongly influenced by environmental conditions.
Important factors:
Temperature
Rainfall
Soil quality
Terrain type
Elevation
Nearby water
Sunlight
Seasonality
Examples:
High rainfall increases trees, moss, ferns, and fungi.
Low rainfall increases cacti, succulents, dry shrubs, and sparse grasses.
Cold climate increases moss, lichen, conifers, and low shrubs.
High elevation reduces large trees and favors alpine plants.
Wet terrain increases reeds, aquatic plants, mangroves, and swamp trees.
Poor soil reduces plant density.
Fertile soil increases plant diversity and size.
7. Regional Distribution
Like fauna, flora should check neighboring cells to create a more natural and homogeneous distribution.
Rules could be:
Same biome and same climate: high probability of sharing the same flora.
Same climate but different terrain: moderate probability of similar flora.
Different climate: generate new flora.
Nearby rivers, lakes, and coasts can spread wetland or aquatic plants.
Mountain ranges, deserts, and oceans can act as barriers to plant spread.
This means forests should gradually change into grasslands or swamps instead of switching completely from one cell to the next.
8. Rarity and Special Plants
The system should support common, uncommon, rare, and unique flora.
Possible rarity levels:
Common
Uncommon
Rare
Very rare
Unique
Examples:
text
Common: Grass, moss, reeds
Uncommon: Berry bushes, medicinal herbs
Rare: Carnivorous plants, glowing mushrooms
Very Rare: Ancient trees, magical flowers
Unique: World-tree fragment, legendary herb
Rarity can be affected by biome and world rules.
For example:
Carnivorous plants are rare in swamps.
Glowing mushrooms are rare in caves or dark forests.
Ancient trees are rare in old forests.
Medicinal herbs are uncommon in mountains or forests.
9. Ecological Role
Each plant type should have an ecological role. This can later connect flora to fauna, crafting, survival, or gameplay systems.
Possible roles:
Food source
Shelter
Nesting material
Medicine
Poison
Crafting material
Building material
Fuel
Soil stabilizer
Water indicator
Magical / special resource
Example:
text
Plant: Berry bush
Growth Form: Bush
Size: Small
Climate: Temperate
Water Requirement: Medium
Role: Food source for animals and humans
Example:
text
Plant: Thorn shrub
Growth Form: Shrub
Size: Small
Climate: Arid
Water Requirement: Low
Role: Shelter for small animals, natural barrier
10. Overall Goal
The goal is to create a flora system that feels natural, biome-based, and regionally consistent. Plants should be generated from broad categories, growth forms, ecological roles, and environmental requirements rather than needing to define every exact species.
The flora system should also support later gameplay features such as animal habitats, food chains, crafting materials, medicine, poison, and rare discoveries.

View File

@ -88,10 +88,40 @@ const char* colorModeName(ColorMode m) {
case ColorMode::Biome: return "Biome";
case ColorMode::Temperature: return "Temperature";
case ColorMode::Precip: return "Precipitation";
case ColorMode::FloraDensity: return "Flora density";
case ColorMode::FaunaDensity: return "Fauna density";
case ColorMode::FungaDensity: return "Funga density";
}
return "?";
}
// Two-colour density ramp helper: barren -> rich.
static Color ramp2(double d01, const unsigned char lo[3], const unsigned char hi[3]) {
double t = std::clamp(d01, 0.0, 1.0);
auto L = [&](int c) { return (unsigned char)(lo[c] + (hi[c] - lo[c]) * t); };
return Color{ L(0), L(1), L(2), 255 };
}
Color floraColor(double d01) { // barren tan -> lush green
static const unsigned char lo[3] = { 200, 190, 150 }, hi[3] = { 25, 120, 35 };
return ramp2(d01, lo, hi);
}
Color faunaColor(double d01) { // pale -> amber -> red
double t = std::clamp(d01, 0.0, 1.0);
static const unsigned char key[3][3] = {
{ 225, 220, 195 }, // 0.0 pale
{ 220, 160, 60 }, // 0.5 amber
{ 180, 55, 40 }, // 1.0 red
};
double s = t * 2.0; int k = std::min(1, (int)s); double f = s - k;
auto L = [&](int c) { return (unsigned char)(key[k][c] + (key[k + 1][c] - key[k][c]) * f); };
return Color{ L(0), L(1), L(2), 255 };
}
Color fungaColor(double d01) { // pale -> violet/brown
static const unsigned char lo[3] = { 215, 205, 210 }, hi[3] = { 110, 55, 120 };
return ramp2(d01, lo, hi);
}
// Temperature ramp over ~[-40, 40] C: deep blue -> cyan -> green -> yellow -> red.
Color tempColor(double celsius) {
double t = std::clamp((celsius + 40.0) / 80.0, 0.0, 1.0); // 0 cold .. 1 hot

View File

@ -4,7 +4,8 @@
// Cell color mapping for the viewer. Pure functions of cell properties.
enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip };
enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip,
FloraDensity, FaunaDensity, FungaDensity };
Color elevationColor(double e, double seaLevel);
Color plateColor(int id);
@ -21,3 +22,8 @@ const char* colorModeName(ColorMode m);
// (tan dry -> green -> blue wet).
Color tempColor(double celsius);
Color precipColor(double moist01);
// Biota density ramps (0..1): flora barren->lush green, fauna pale->amber/red,
// funga pale->violet/brown.
Color floraColor(double d01);
Color faunaColor(double d01);
Color fungaColor(double d01);

View File

@ -1,10 +1,34 @@
#include "Panels.hpp"
#include "Colors.hpp" // elevationColor (subtile grid)
#include "PlanetBiota.hpp" // organismName / sizeName / roleName
#include "Projection.hpp" // dirToLonLat
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
#include <sstream>
// Draw `text` word-wrapped to `maxW` pixels starting at (x,y); continuation lines
// are indented. Returns the y after the last line; stops drawing past `maxY` (but
// keeps advancing y so callers can detect the overflow). Long biota lists would
// otherwise run off the right edge of the cell-info panel.
static int drawWrapped(const std::string& text, int x, int y, int font, Color col,
int maxW, int lineH, int maxY) {
std::istringstream iss(text);
std::string word, line;
int indent = 0;
auto flush = [&]() {
if (!line.empty()) { if (y + lineH <= maxY) DrawText(line.c_str(), x + indent, y, font, col);
y += lineH; line.clear(); indent = 14; }
};
while (iss >> word) {
std::string test = line.empty() ? word : line + " " + word;
if (MeasureText(test.c_str(), font) > maxW - indent && !line.empty()) { flush(); line = word; }
else line = test;
}
flush();
return y;
}
// elev/age come from the display snapshot so the readout matches what is drawn.
static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, double age) {
@ -32,6 +56,39 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
L.push_back(std::string(TextFormat("river: discharge %.0f", p.discharge()[i])));
if (sized(p.lakeDepth()) && p.lakeDepth()[i] > p.cfg.biomeLakeMinDepth && elev > p.cfg.seaLevel)
L.push_back(std::string(TextFormat("lake: depth %.0f m", p.lakeDepth()[i])));
// Biota: density scalars (present after computeBiotaDensity()) + the discrete
// population list (present once generateBiota()/L has run).
if (sized(p.floraDensity()) && sized(p.faunaDensity()) && sized(p.fungaDensity()))
L.push_back(std::string(TextFormat("flora %.0f%% fauna %.0f%% funga %.0f%%",
p.floraDensity()[i] * 100.0, p.faunaDensity()[i] * 100.0, p.fungaDensity()[i] * 100.0)));
if (p.biotaPopulated() && i < (int)p.biota().size()) {
const CellBiota& cb = p.biota()[i];
// Each organism reads as Family (Size, Role) -- proper taxonomy, never an
// informal common name; generalists carry a biome adjective ("Forest Felidae").
// Identical archetypes in a cell aggregate to "... xN" so the list stays clean.
auto listKind = [&](const char* tag, const std::vector<Organism>& v) {
if (v.empty()) return;
std::vector<std::pair<Organism, int>> uniq; // representative + count, first-seen order
for (const Organism& o : v) {
bool found = false;
for (auto& u : uniq) if (u.first.archetype == o.archetype) { ++u.second; found = true; break; }
if (!found) uniq.push_back({o, 1});
}
std::string s = tag;
int shown = (int)std::min<size_t>(uniq.size(), 6);
for (int k = 0; k < shown; ++k) {
const BiotaArchetype& a = biotaArchetypes()[uniq[k].first.archetype];
s += (k ? ", " : " ") + organismName(uniq[k].first) +
" (" + sizeName(a.size) + ", " + roleName(a.role) + ")";
if (uniq[k].second > 1) s += TextFormat(" x%d", uniq[k].second);
}
if ((int)uniq.size() > shown) s += TextFormat(", +%d more", (int)uniq.size() - shown);
L.push_back(s);
};
listKind("Flora:", cb.flora);
listKind("Fauna:", cb.fauna);
listKind("Funga:", cb.funga);
}
return L;
}
@ -46,9 +103,10 @@ void drawDetailPanel(const Planet& p, const std::shared_ptr<SubGrid>& sg,
ty += 28;
// Cramped above the subtile grid -> stop before overlapping it (the full list is
// always shown in the top-right hover panel, which has room).
int infoMaxW = (int)(panel.x + panel.width) - tx - 10;
for (auto& s : cellInfo(p, macro, macroElev, macroAge)) {
if (ty + 18 > (int)grid.y) break;
DrawText(s.c_str(), tx, ty, 15, Color{210, 210, 220, 255}); ty += 18;
if (ty + 16 > (int)grid.y) break;
ty = drawWrapped(s, tx, ty, 14, Color{210, 210, 220, 255}, infoMaxW, 16, (int)grid.y);
}
if (!sg || sg->res < 2) return;
@ -97,8 +155,11 @@ void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected) {
return;
}
if (hovered < 0) { DrawText("(selected tile)", x, y, 18, Color{210, 180, 120, 255}); y += 30; }
int maxW = (int)(r.x + r.width) - x - 14; // wrap to the panel's inner width
int maxY = (int)(r.y + r.height) - 10; // clamp to the panel bottom
for (auto& s : cellInfo(p, shown, p.cells[shown].elevation, p.cells[shown].geoAge)) {
DrawText(s.c_str(), x, y, 24, Color{215, 220, 230, 255}); y += 32;
y = drawWrapped(s, x, y, 20, Color{215, 220, 230, 255}, maxW, 26, maxY);
if (y > maxY) break;
}
}

View File

@ -97,6 +97,9 @@ void Viewer::recolor() {
double maxAge = 1.0; for (const auto& c : planet.cells) maxAge = std::max(maxAge, c.geoAge);
const std::vector<double>& temp = planet.temperature();
const std::vector<double>& moist = planet.moisture(); // 0..1, already robustly normalized
const std::vector<double>& flora = planet.floraDensity();
const std::vector<double>& fauna = planet.faunaDensity();
const std::vector<double>& funga = planet.fungaDensity();
vcolors.resize(planet.cells.size());
for (size_t i = 0; i < planet.cells.size(); ++i) {
switch (mode) {
@ -112,6 +115,9 @@ void Viewer::recolor() {
case ColorMode::Biome: vcolors[i] = biomeColor(planet.cells[i].biome); break;
case ColorMode::Temperature: vcolors[i] = temp.empty() ? Color{90,90,90,255} : tempColor(temp[i]); break;
case ColorMode::Precip: vcolors[i] = moist.empty() ? Color{90,90,90,255} : precipColor(moist[i]); break;
case ColorMode::FloraDensity: vcolors[i] = flora.empty() ? Color{90,90,90,255} : floraColor(flora[i]); break;
case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255} : faunaColor(fauna[i]); break;
case ColorMode::FungaDensity: vcolors[i] = funga.empty() ? Color{90,90,90,255} : fungaColor(funga[i]); break;
default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel);
}
}
@ -128,6 +134,7 @@ void Viewer::refreshView() {
if (phase3) planet.computeHydrology(); // refresh lakes/rivers for the view
planet.computeClimate(); // temperature + precipitation fields
planet.classifyBiomes(); // keep cell.biome current (reads the climate)
planet.computeBiotaDensity(); // flora/fauna/funga density (population is on-demand)
recolor();
if (settled) { // Phase 2: plates moved -> boundaries moved
buildBorders(planet, borderR, borders, ridgeBorders);
@ -188,7 +195,7 @@ void Viewer::loadGame(const char* path) {
if (ver >= 2) is.read(reinterpret_cast<char*>(&dr), sizeof dr);
if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3);
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4: per-cell biome
if (!planet.readState(is, ver >= 4, ver >= 7)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4: biome, v7: biota
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 6; // v6: self-describing (text) config block; v4: +biome; v3: +phase3
static constexpr uint32_t SAVE_VERSION = 7; // v7: +biota population; v6: self-describing config; v4: +biome; v3: +phase3
const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides)

View File

@ -104,6 +104,9 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_FIVE)) { mode = ColorMode::Biome; recolor(); }
if (IsKeyPressed(KEY_SIX)) { mode = ColorMode::Temperature; recolor(); }
if (IsKeyPressed(KEY_SEVEN)) { mode = ColorMode::Precip; recolor(); }
if (IsKeyPressed(KEY_EIGHT)) { mode = ColorMode::FloraDensity; recolor(); }
if (IsKeyPressed(KEY_NINE)) { mode = ColorMode::FaunaDensity; recolor(); }
if (IsKeyPressed(KEY_ZERO)) { mode = ColorMode::FungaDensity; recolor(); }
if (IsKeyPressed(KEY_B)) showBorders = !showBorders;
if (IsKeyPressed(KEY_D)) showDrift = !showDrift;
if (IsKeyPressed(KEY_G)) showGrat = !showGrat;
@ -116,6 +119,12 @@ void Viewer::handleInput() {
else { phase3PromptAt = elapsedMy + planet.cfg.phase3AfterMy; rivers.clear(); bigRivers.clear(); setStatus("Hydrology OFF"); }
refreshView();
}
if (IsKeyPressed(KEY_L) && settled) { // generate / regenerate biota population
planet.generateBiota();
if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity)
{ mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)");
}
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { stepOnce(); refreshView(); } // one tick (handy while paused/settled)

View File

@ -178,11 +178,11 @@ void Viewer::renderHUD() {
}
y += 8;
line("hover: cell info | click tile: open detail panel | C close");
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp 7 precip");
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp 7 precip 8 flora 9 fauna 0 funga");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | R reseed | +/-",
phase3 ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");
if (!statusMsg.empty() && GetTime() < statusUntil) {
y += 4; DrawText(statusMsg.c_str(), 12, y, 18, Color{120, 230, 140, 255}); y += 22;

View File

@ -35,6 +35,7 @@ void Planet::generate(const PlanetConfig& c) {
seedInitialRelief();
computeClimate(); // temperature + precipitation fields (biomes read these)
classifyBiomes(); // give the fresh world an initial biome per cell
computeBiotaDensity(); // derived flora/fauna/funga density (population is on-demand)
}
// Build the icosphere and copy fixed geometry (unit direction + neighbor
@ -47,6 +48,8 @@ void Planet::buildGeometry() {
cells[i].unit = sphere.positions[i];
cells[i].neighbors = sphere.neighbors[i];
}
sBiota.assign(cells.size(), {}); // empty biota population until generateBiota()
sHasBiota = false;
}
void Planet::assignPlates() {

View File

@ -2,6 +2,7 @@
#include "Vec3.hpp"
#include "IcoSphere.hpp"
#include "PlanetTypes.hpp" // Cell, Plate, SubGrid/SubCell, PlanetConfig
#include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota
#include <vector>
#include <memory>
#include <cstdint>
@ -58,6 +59,20 @@ public:
// fields (temperature + normalized precipitation). Derived + written back into
// cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves.
void classifyBiomes();
// Biota stage (flora/fauna/funga). computeBiotaDensity() builds the derived
// per-cell density scalars (0..1) each tick (like climate; not saved); call it
// after classifyBiomes(). generateBiota() does the on-demand slot/point fill of
// the discrete population into sBiota (saved) -- NOT called per tick. See
// PlanetBiota.cpp + PlanetFloraGen/FaunaGen/FungiGen.cpp.
void computeBiotaDensity();
void generateBiota();
bool biotaPopulated() const;
const std::vector<double>& floraDensity() const { return sFloraDensity; }
const std::vector<double>& faunaDensity() const { return sFaunaDensity; }
const std::vector<double>& fungaDensity() const { return sFungaDensity; }
const std::vector<CellBiota>& biota() const { return sBiota; }
// Derived hydrology fields (recomputed each route; not saved). Empty until
// the first computeHydrology()/hydrology() call.
const std::vector<double>& lakeDepth() const { return sLakeDepth; }
@ -73,7 +88,8 @@ public:
void writeState(std::ostream& os) const;
// hasBiome: whether the stream carries the per-cell biome byte (save v4+). For
// older saves (v3) pass false -- biomes are reclassified after the cells load.
bool readState(std::istream& is, bool hasBiome = true);
// hasBiota: whether the stream carries the biota population block (save v7+).
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true);
// Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing
@ -110,6 +126,17 @@ private:
// Phase-3 hydrology helpers (see hydrology()).
void routeFlow(); // depression-fill -> lakes, flow, discharge
// Biota helpers (PlanetFloraGen/FaunaGen/FungiGen.cpp). compute*Density write the
// derived scalars; fill* draw the per-cell population (nbr = already-filled,
// same-biome neighbours, for regional consistency).
void computeFloraDensity();
void computeFaunaDensity();
void computeFungaDensity();
double neighbourhoodPrey(int i) const; // mean fauna density over i + neighbours
std::vector<Organism> fillFlora(int i, const std::vector<int>& nbr, uint32_t& rng);
std::vector<Organism> fillFauna(int i, const std::vector<int>& nbr, uint32_t& rng);
std::vector<Organism> fillFunga(int i, const std::vector<int>& nbr, uint32_t& rng);
int driftIter = 0; // counts advect() calls (gates periodic checks)
int erodeIter = 0; // counts erode() calls (gates sea-level control)
std::vector<int> sPrevCount; // per-plate cell count at the previous check
@ -129,6 +156,12 @@ private:
std::vector<double> sTemp, sPrecip, sMoist;
std::vector<Vec3> sWind;
std::vector<int> sUpwind;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.
std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity;
std::vector<CellBiota> sBiota;
bool sHasBiota = false;
};
// Human-editable config file (key = value text). All PlanetConfig input

244
src/sim/PlanetBiota.cpp Normal file
View File

@ -0,0 +1,244 @@
#include "Planet.hpp"
#include "PlanetBiota.hpp"
#include <algorithm>
#include <cmath>
#include <initializer_list>
// --- Biota stage: archetype library + shared slot/point machinery + driver ----
// The per-kind environmental rules + density live in PlanetFloraGen/FaunaGen/
// FungiGen.cpp; this file holds the comprehensive archetype table, the display
// helpers, the shared draw (slot/point) routine, and the on-demand generateBiota()
// driver that fills the discrete population with neighbour-aware regional spread.
// Comprehensive archetype library. APPEND-ONLY: indices are serialized inside
// saved Organisms, so never reorder or delete entries (add new ones at the end).
const std::vector<BiotaArchetype>& biotaArchetypes() {
using K = BiotaKind; using R = EcoRole; using S = SizeClass; using B = Biome;
static const std::vector<BiotaArchetype> T = [] {
auto M = [](std::initializer_list<B> bs) {
uint32_t m = 0; for (B b : bs) m |= 1u << (unsigned)b; return m;
};
std::vector<BiotaArchetype> a;
// ---- Flora ----------------------------------------------------------
a.push_back({ "Broadleaf tree", K::Flora, R::Tree, "Magnoliopsida", "Fagales", "Fagaceae",
S::Big, M({B::Forest, B::Hills, B::Wetland, B::Grassland}), 4.0, 35.0, 0.45, false });
a.push_back({ "Conifer", K::Flora, R::Tree, "Pinopsida", "Pinales", "Pinaceae",
S::Big, M({B::Taiga, B::Forest, B::Mountains, B::Hills}), -12.0, 20.0, 0.28, false });
a.push_back({ "Tropical palm", K::Flora, R::Tree, "Liliopsida", "Arecales", "Arecaceae",
S::Medium, M({B::Forest, B::Beach, B::Wetland}), 18.0, 42.0, 0.40, false });
a.push_back({ "Tall grass", K::Flora, R::Grass, "Liliopsida", "Poales", "Poaceae",
S::Tiny, M({B::Grassland, B::Savanna, B::Wetland}), 2.0, 38.0, 0.25, false });
a.push_back({ "Steppe grass", K::Flora, R::Grass, "Liliopsida", "Poales", "Poaceae",
S::Tiny, M({B::Grassland, B::Savanna, B::Tundra, B::Desert}), -6.0, 38.0, 0.10, true });
a.push_back({ "Scrub", K::Flora, R::Shrub, "Magnoliopsida", "Lamiales", "Lamiaceae",
S::Small, M({B::Savanna, B::Grassland, B::Desert, B::Hills, B::Tundra}), -6.0, 40.0, 0.08, true });
a.push_back({ "Succulent", K::Flora, R::Succulent, "Magnoliopsida", "Caryophyllales", "Cactaceae",
S::Small, M({B::Desert, B::Savanna}), 4.0, 48.0, 0.0, false });
a.push_back({ "Reed", K::Flora, R::Aquatic, "Liliopsida", "Poales", "Cyperaceae",
S::Small, M({B::Wetland, B::Beach}), 2.0, 36.0, 0.50, false });
a.push_back({ "Mangrove", K::Flora, R::Tree, "Magnoliopsida", "Malpighiales", "Rhizophoraceae",
S::Medium, M({B::Beach, B::Wetland}), 16.0, 40.0, 0.40, false });
a.push_back({ "Fern", K::Flora, R::Shrub, "Polypodiopsida", "Polypodiales", "Polypodiaceae",
S::Tiny, M({B::Forest, B::Taiga, B::Wetland}), 0.0, 32.0, 0.40, false });
a.push_back({ "Cushion moss", K::Flora, R::Moss, "Bryopsida", "Bryales", "Bryaceae",
S::Tiny, M({B::Tundra, B::Mountains, B::Taiga}), -28.0, 10.0, 0.15, false });
a.push_back({ "Alpine wildflower", K::Flora, R::Shrub, "Magnoliopsida", "Saxifragales", "Saxifragaceae",
S::Tiny, M({B::Mountains, B::Hills, B::Tundra}), -16.0, 16.0, 0.20, true });
a.push_back({ "Giant canopy tree", K::Flora, R::Tree, "Magnoliopsida", "Malvales", "Malvaceae",
S::Huge, M({B::Forest}), 18.0, 40.0, 0.60, false }); // rainforest emergent
a.push_back({ "Berry bush", K::Flora, R::Shrub, "Magnoliopsida", "Rosales", "Rosaceae",
S::Small, M({B::Forest, B::Taiga, B::Grassland, B::Hills}), -8.0, 28.0, 0.35, false });
a.push_back({ "Wildflower", K::Flora, R::Shrub, "Magnoliopsida", "Asterales", "Asteraceae",
S::Tiny, M({B::Grassland, B::Savanna, B::Hills, B::Forest}), 2.0, 35.0, 0.20, false });
a.push_back({ "Water lily", K::Flora, R::Aquatic, "Magnoliopsida", "Nymphaeales", "Nymphaeaceae",
S::Tiny, M({B::Wetland}), 8.0, 36.0, 0.60, false });
// ---- Fauna (named by Family; `name` is the family vernacular, display uses
// the Family rank -- e.g. Felidae, never "cat") -------------------
a.push_back({ "Murid", K::Fauna, R::Herbivore, "Mammalia", "Rodentia", "Muridae",
S::Tiny, M({B::Grassland, B::Savanna, B::Forest, B::Taiga, B::Desert,
B::Wetland, B::Tundra, B::Hills, B::Mountains, B::Beach}), -12.0, 42.0, 0.0, true });
a.push_back({ "Leporid", K::Fauna, R::Herbivore, "Mammalia", "Lagomorpha", "Leporidae",
S::Small, M({B::Grassland, B::Savanna, B::Tundra, B::Forest, B::Desert}), -16.0, 38.0, 0.05, true });
a.push_back({ "Cervid", K::Fauna, R::Herbivore, "Mammalia", "Artiodactyla", "Cervidae",
S::Medium, M({B::Forest, B::Grassland, B::Savanna, B::Taiga, B::Hills}), -12.0, 35.0, 0.20, false });
a.push_back({ "Bovid", K::Fauna, R::Herbivore, "Mammalia", "Artiodactyla", "Bovidae",
S::Big, M({B::Grassland, B::Savanna}), 0.0, 40.0, 0.18, false });
a.push_back({ "Proboscid", K::Fauna, R::Herbivore, "Mammalia", "Proboscidea", "Elephantidae",
S::Huge, M({B::Savanna, B::Grassland, B::Forest}), 10.0, 42.0, 0.30, false });
a.push_back({ "Suid", K::Fauna, R::Omnivore, "Mammalia", "Artiodactyla", "Suidae",
S::Medium, M({B::Forest, B::Wetland, B::Grassland, B::Hills}), -2.0, 36.0, 0.25, false });
a.push_back({ "Ursid", K::Fauna, R::Omnivore, "Mammalia", "Carnivora", "Ursidae",
S::Big, M({B::Forest, B::Taiga, B::Mountains, B::Tundra}), -22.0, 26.0, 0.20, false });
a.push_back({ "Cercopithecid", K::Fauna, R::Omnivore, "Mammalia", "Primates", "Cercopithecidae",
S::Medium, M({B::Forest, B::Wetland}), 15.0, 40.0, 0.45, false });
a.push_back({ "Felid (small)", K::Fauna, R::Carnivore, "Mammalia", "Carnivora", "Felidae",
S::Small, M({B::Forest, B::Grassland, B::Savanna, B::Desert, B::Wetland, B::Hills}), 0.0, 42.0, 0.10, true });
a.push_back({ "Felid (large)", K::Fauna, R::Carnivore, "Mammalia", "Carnivora", "Felidae",
S::Big, M({B::Savanna, B::Grassland, B::Forest, B::Hills}), 5.0, 42.0, 0.18, true });
a.push_back({ "Canid", K::Fauna, R::Carnivore, "Mammalia", "Carnivora", "Canidae",
S::Medium, M({B::Grassland, B::Savanna, B::Forest, B::Taiga, B::Tundra,
B::Desert, B::Hills, B::Mountains}), -26.0, 40.0, 0.05, true });
a.push_back({ "Mustelid", K::Fauna, R::Carnivore, "Mammalia", "Carnivora", "Mustelidae",
S::Tiny, M({B::Forest, B::Taiga, B::Wetland, B::Grassland, B::Tundra}), -22.0, 30.0, 0.15, false });
a.push_back({ "Varanid", K::Fauna, R::Carnivore, "Reptilia", "Squamata", "Varanidae",
S::Small, M({B::Desert, B::Savanna}), 12.0, 50.0, 0.0, false });
a.push_back({ "Ranid", K::Fauna, R::Omnivore, "Amphibia", "Anura", "Ranidae",
S::Tiny, M({B::Wetland, B::Forest, B::Beach}), 5.0, 35.0, 0.50, false });
a.push_back({ "Phasianid", K::Fauna, R::Herbivore, "Aves", "Galliformes", "Phasianidae",
S::Medium, M({B::Grassland, B::Savanna, B::Tundra, B::Desert, B::Forest}), -12.0, 42.0, 0.05, true });
a.push_back({ "Accipitrid", K::Fauna, R::Carnivore, "Aves", "Accipitriformes", "Accipitridae",
S::Small, M({B::Grassland, B::Savanna, B::Forest, B::Mountains, B::Tundra,
B::Desert, B::Hills, B::Wetland}), -16.0, 42.0, 0.0, true });
// ---- Funga ----------------------------------------------------------
a.push_back({ "Decomposer mushroom", K::Funga, R::Decomposer, "Agaricomycetes", "Agaricales", "Agaricaceae",
S::Tiny, M({B::Forest, B::Taiga, B::Wetland, B::Grassland, B::Hills}), -6.0, 32.0, 0.35, true });
a.push_back({ "Mould", K::Funga, R::Decomposer, "Eurotiomycetes", "Eurotiales", "Aspergillaceae",
S::Tiny, M({B::Wetland, B::Forest, B::Beach}), 0.0, 40.0, 0.45, false });
a.push_back({ "Mycorrhizal fungus", K::Funga, R::Mycorrhizal, "Agaricomycetes", "Boletales", "Boletaceae",
S::Tiny, M({B::Forest, B::Taiga, B::Hills}), -12.0, 30.0, 0.30, false });
a.push_back({ "Bracket fungus", K::Funga, R::Decomposer, "Agaricomycetes", "Polyporales", "Polyporaceae",
S::Small, M({B::Forest, B::Taiga}), -6.0, 28.0, 0.40, false });
a.push_back({ "Lichen", K::Funga, R::Lichen, "Lecanoromycetes", "Lecanorales", "Parmeliaceae",
S::Tiny, M({B::Tundra, B::Mountains, B::Taiga, B::Beach}), -32.0, 16.0, 0.10, true });
a.push_back({ "Slime mould", K::Funga, R::Decomposer, "Myxomycetes", "Physarales", "Physaraceae",
S::Tiny, M({B::Forest, B::Wetland}), 5.0, 32.0, 0.50, false });
a.push_back({ "Parasitic blight", K::Funga, R::Parasite, "Sordariomycetes", "Hypocreales", "Clavicipitaceae",
S::Tiny, M({B::Forest, B::Grassland, B::Savanna, B::Wetland}), 0.0, 38.0, 0.30, false });
a.push_back({ "Puffball", K::Funga, R::Decomposer, "Agaricomycetes", "Agaricales", "Lycoperdaceae",
S::Small, M({B::Grassland, B::Savanna, B::Tundra}), -10.0, 32.0, 0.20, true });
return a;
}();
return T;
}
int pointCost(SizeClass s) { return (int)s + 1; } // Tiny=1 .. Huge=5
const char* sizeName(SizeClass s) {
switch (s) { case SizeClass::Tiny: return "Tiny"; case SizeClass::Small: return "Small";
case SizeClass::Medium: return "Medium"; case SizeClass::Big: return "Big";
case SizeClass::Huge: return "Huge"; }
return "?";
}
const char* roleName(EcoRole r) {
switch (r) {
case EcoRole::Grass: return "Grass"; case EcoRole::Shrub: return "Shrub";
case EcoRole::Tree: return "Tree"; case EcoRole::Succulent: return "Succulent";
case EcoRole::Moss: return "Moss"; case EcoRole::Aquatic: return "Aquatic";
case EcoRole::Herbivore: return "Herbivore"; case EcoRole::Carnivore: return "Carnivore";
case EcoRole::Omnivore: return "Omnivore"; case EcoRole::Decomposer: return "Decomposer";
case EcoRole::Mycorrhizal: return "Mycorrhizal"; case EcoRole::Lichen: return "Lichen";
case EcoRole::Parasite: return "Parasite";
}
return "?";
}
const char* kindName(BiotaKind k) {
switch (k) { case BiotaKind::Flora: return "Flora"; case BiotaKind::Fauna: return "Fauna";
case BiotaKind::Funga: return "Funga"; }
return "?";
}
namespace {
// Adjective prefix for generalist display names ("Desert rodent" etc.).
const char* biomeAdjective(Biome b) {
switch (b) {
case Biome::Desert: return "Desert ";
case Biome::Forest: return "Forest ";
case Biome::Wetland: return "Swamp ";
case Biome::Savanna: return "Savanna ";
case Biome::Grassland: return "Steppe ";
case Biome::Taiga: return "Boreal ";
case Biome::Tundra: return "Tundra ";
case Biome::Mountains: return "Mountain ";
case Biome::Hills: return "Hill ";
case Biome::Beach: return "Coastal ";
default: return "";
}
}
}
// Display label is built from the TAXONOMY (Family), not an informal common name --
// e.g. a Felidae of Big size reads "Felidae", never "big cat" (cat is a common name,
// Felidae is the family). Generalists get a biome adjective ("Forest Felidae").
std::string organismName(const Organism& o) {
const auto& AR = biotaArchetypes();
if (o.archetype >= AR.size()) return "?";
const BiotaArchetype& a = AR[o.archetype];
std::string fam = a.familyName;
return a.generalist ? std::string(biomeAdjective((Biome)o.biome)) + fam : fam;
}
// Full Class > Order > Family taxonomy string (the "tree" path) for an organism.
std::string organismTaxonomy(const Organism& o) {
const auto& AR = biotaArchetypes();
if (o.archetype >= AR.size()) return "?";
const BiotaArchetype& a = AR[o.archetype];
return std::string(a.className) + " > " + a.orderName + " > " + a.familyName;
}
double biotaSuitability(const BiotaArchetype& a, Biome biome, double temp, double moist) {
if (!(a.biomeMask & (1u << (unsigned)biome))) return 0.0;
double tf = 1.0;
if (temp < a.tempMin) tf = std::max(0.0, 1.0 - (a.tempMin - temp) / 8.0);
else if (temp > a.tempMax) tf = std::max(0.0, 1.0 - (temp - a.tempMax) / 8.0);
double mf = (moist >= a.moistMin) ? 1.0 : std::max(0.0, 1.0 - (a.moistMin - moist) / 0.2);
return tf * mf;
}
std::vector<Organism> biotaDraw(const std::vector<BiotaCandidate>& cands, Biome biome,
int maxSlots, int maxPoints, uint32_t& rng) {
std::vector<Organism> out;
if (cands.empty() || maxSlots <= 0 || maxPoints <= 0) return out;
const auto& AR = biotaArchetypes();
double total = 0.0; for (const auto& c : cands) total += c.weight;
if (total <= 0.0) return out;
int minCost = 99; for (const auto& c : cands) minCost = std::min(minCost, pointCost(AR[c.arch].size));
int slots = maxSlots, pts = maxPoints, guard = maxSlots * 8;
while (slots > 0 && pts >= minCost && guard-- > 0) {
double r = biotaRndf(rng) * total, acc = 0.0; int pick = cands.back().arch;
for (const auto& c : cands) { acc += c.weight; if (r <= acc) { pick = c.arch; break; } }
int cost = pointCost(AR[pick].size);
if (cost <= pts) { out.push_back({ (uint16_t)pick, (uint8_t)biome }); --slots; pts -= cost; }
}
return out;
}
// --- Planet driver -----------------------------------------------------------
void Planet::computeBiotaDensity() {
const int n = (int)cells.size();
if ((int)sTemp.size() != n || (int)sMoist.size() != n) computeClimate(); // safety
computeFloraDensity();
computeFaunaDensity(); // reads sFloraDensity
computeFungaDensity(); // reads sFloraDensity
}
bool Planet::biotaPopulated() const { return sHasBiota; }
// On-demand: fill the discrete slot/point population. Deterministic (separate RNG
// seeded from cfg.seed, so it never perturbs the tectonic stream). Regional
// consistency comes from biasing each cell's draws toward archetypes already
// placed in its already-filled, same-biome neighbours (single index-ordered pass).
void Planet::generateBiota() {
const int n = (int)cells.size();
if ((int)sFloraDensity.size() != n || (int)sFaunaDensity.size() != n ||
(int)sFungaDensity.size() != n) computeBiotaDensity();
sBiota.assign(n, {});
sHasBiota = false;
const double sea = cfg.seaLevel;
uint32_t rng = cfg.seed ? (cfg.seed ^ 0xB107A5EDu) : 0xB107A5EDu;
std::vector<char> done(n, 0);
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || cells[i].biome == Biome::Ice) { done[i] = 1; continue; }
std::vector<int> nbr; // already-filled neighbours sharing this biome
for (int j : cells[i].neighbors)
if (done[j] && cells[j].biome == cells[i].biome) nbr.push_back(j);
sBiota[i].flora = fillFlora(i, nbr, rng);
sBiota[i].fauna = fillFauna(i, nbr, rng);
sBiota[i].funga = fillFunga(i, nbr, rng);
if (!sBiota[i].flora.empty() || !sBiota[i].fauna.empty() || !sBiota[i].funga.empty())
sHasBiota = true;
done[i] = 1;
}
}

81
src/sim/PlanetBiota.hpp Normal file
View File

@ -0,0 +1,81 @@
#pragma once
#include "PlanetTypes.hpp" // Biome
#include <vector>
#include <string>
#include <cstdint>
// --- Biota stage: Flora / Fauna / Funga --------------------------------------
// Two layers (see docs/fauna-flora-plan.md + docs/fauna_generation_plan.md):
// * density scalars (0..1) per kind, derived from climate each tick (drive the
// colour views) -- "how full a cell gets";
// * a discrete slot/point POPULATION of broad archetypes, generated on demand
// and saved -- "what fills it".
// Raylib-free, deterministic. Geometry never moves: biota is per-cell data flowed
// over the fixed grid (see CLAUDE.md core principle).
enum class BiotaKind : uint8_t { Flora, Fauna, Funga };
// Size category -> point cost (Tiny=1 .. Huge=5). Keep order stable (serialized
// indirectly via the archetype table).
enum class SizeClass : uint8_t { Tiny, Small, Medium, Big, Huge };
// Functional / trophic role. Grouped by kind; append new roles at the end.
enum class EcoRole : uint8_t {
Grass, Shrub, Tree, Succulent, Moss, Aquatic, // flora growth forms
Herbivore, Carnivore, Omnivore, // fauna trophic roles
Decomposer, Mycorrhizal, Lichen, Parasite // funga roles
};
// A broad biological group (NOT a real species). The world is populated by
// drawing these into cells; a "generalist" gets a biome adjective at display time
// ("Desert rodent" / "Forest rodent"), per the fauna doc.
struct BiotaArchetype {
const char* name;
BiotaKind kind;
EcoRole role;
const char* className; // taxonomy labels (broad, illustrative)
const char* orderName;
const char* familyName;
SizeClass size;
uint32_t biomeMask; // OR of (1u << (int)Biome) for the biomes it inhabits
double tempMin, tempMax; // climate tolerance, deg C
double moistMin; // min normalized moisture (0..1)
bool generalist; // display name gets a biome adjective
};
// One placed organism (saved). `archetype` indexes the global table; `biome` is
// the cell's biome at placement time (for the display adjective).
struct Organism { uint16_t archetype; uint8_t biome; };
// Per-cell population, one list per kind (saved as part of the planet state).
struct CellBiota { std::vector<Organism> flora, fauna, funga; };
// Global archetype table (append-only / never reorder -- indices are serialized,
// same convention as the Biome enum). Defined in PlanetBiota.cpp.
const std::vector<BiotaArchetype>& biotaArchetypes();
// Helpers (PlanetBiota.cpp).
int pointCost(SizeClass s); // 1..5
const char* sizeName(SizeClass s);
const char* roleName(EcoRole r);
const char* kindName(BiotaKind k);
std::string organismName(const Organism& o); // family-based label (generalists get a biome adjective)
std::string organismTaxonomy(const Organism& o); // "Class > Order > Family" path
// Environmental suitability of an archetype in a cell (0 = can't live here).
double biotaSuitability(const BiotaArchetype& a, Biome biome, double temp, double moist);
// One candidate archetype + its draw weight for a cell.
struct BiotaCandidate { int arch; double weight; };
// Slot/point fill: draw archetypes weighted by `weight` until slots full OR points
// exhausted OR nothing affordable remains. Deterministic given `rng`.
std::vector<Organism> biotaDraw(const std::vector<BiotaCandidate>& cands, Biome biome,
int maxSlots, int maxPoints, uint32_t& rng);
// Local xorshift RNG (kept separate from Planet::rngState so generating biota
// never perturbs tectonic determinism).
inline uint32_t biotaRnd(uint32_t& s) {
uint32_t x = s; x ^= x << 13; x ^= x >> 17; x ^= x << 5; s = x; return x;
}
inline double biotaRndf(uint32_t& s) { return (biotaRnd(s) & 0xFFFFFF) / double(0x1000000); }

View File

@ -0,0 +1,66 @@
#include "Planet.hpp"
#include "PlanetBiota.hpp"
#include <algorithm>
#include <cmath>
// --- Biota: Fauna (animals) --------------------------------------------------
// Density tracks herbivore carrying capacity (~ flora productivity); carnivores
// are gated on local prey abundance so predators concentrate in the richest belts
// (an energy pyramid). fillFauna() places herbivores/omnivores from the prey pool,
// then carnivores only where the neighbourhood prey clears bioCarnPreyMin.
void Planet::computeFaunaDensity() {
const int n = (int)cells.size();
if ((int)sFloraDensity.size() != n) computeFloraDensity();
sFaunaDensity.assign(n, 0.0);
const double sea = cfg.seaLevel;
const double prod = cfg.bioFaunaProductivity;
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || cells[i].biome == Biome::Ice) continue;
// Herbivore capacity scales with plant productivity; overall animal
// richness mostly tracks it (herbivores are the bulk of the biomass).
sFaunaDensity[i] = std::clamp(sFloraDensity[i] * prod, 0.0, 1.0);
}
}
// Local prey abundance = mean fauna density over the cell + its neighbours.
double Planet::neighbourhoodPrey(int i) const {
double sum = sFaunaDensity[i]; int c = 1;
for (int j : cells[i].neighbors) { sum += sFaunaDensity[j]; ++c; }
return sum / c;
}
std::vector<Organism> Planet::fillFauna(int i, const std::vector<int>& nbr, uint32_t& rng) {
double dens = sFaunaDensity[i];
int pts = (int)std::lround(cfg.bioFaunaPoints * dens);
if (pts <= 0) return {};
Biome biome = cells[i].biome;
double temp = sTemp[i], moist = sMoist[i];
// Predators present only where prey is abundant; ramp scales their weight.
double prey = neighbourhoodPrey(i);
bool allowCarn = prey > cfg.bioCarnPreyMin;
double carnRamp = allowCarn
? std::clamp((prey - cfg.bioCarnPreyMin) / std::max(1e-6, 1.0 - cfg.bioCarnPreyMin)
* cfg.bioCarnScale, 0.0, 1.0)
: 0.0;
const auto& AR = biotaArchetypes();
std::vector<BiotaCandidate> cands;
for (int a = 0; a < (int)AR.size(); ++a) {
if (AR[a].kind != BiotaKind::Fauna) continue;
bool carn = AR[a].role == EcoRole::Carnivore;
if (carn && !allowCarn) continue;
double s = biotaSuitability(AR[a], biome, temp, moist);
if (s <= 0.0) continue;
if (carn) s *= carnRamp; // rarer predators in poorer regions
if (s <= 0.0) continue;
double pres = 0.0;
if (!nbr.empty()) {
int hit = 0;
for (int j : nbr)
for (const Organism& o : sBiota[j].fauna) if (o.archetype == a) { ++hit; break; }
pres = (double)hit / nbr.size();
}
cands.push_back({ a, s * (1.0 + cfg.bioRegionBonus * pres) });
}
return biotaDraw(cands, biome, cfg.bioFaunaSlots, pts, rng);
}

View File

@ -0,0 +1,48 @@
#include "Planet.hpp"
#include "PlanetBiota.hpp"
#include <algorithm>
#include <cmath>
// --- Biota: Flora (plants) ---------------------------------------------------
// Density = an NPP-style Liebig minimum of a temperature factor and a moisture
// factor (lush warm-wet, ~0 in ice/desert/alpine), 0 on water. fillFlora() draws
// plant archetypes suited to the cell's biome/climate into its slot/point budget.
void Planet::computeFloraDensity() {
const int n = (int)cells.size();
if ((int)sTemp.size() != n || (int)sMoist.size() != n) computeClimate();
sFloraDensity.assign(n, 0.0);
const double sea = cfg.seaLevel;
const double tMin = cfg.bioVegTempMin, tOpt = cfg.bioVegTempOpt;
const double mRef = std::max(1e-6, cfg.bioVegMoistRef);
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || cells[i].biome == Biome::Ice) continue;
double tf = std::clamp((sTemp[i] - tMin) / std::max(1e-6, tOpt - tMin), 0.0, 1.0);
double mf = std::clamp(sMoist[i] / mRef, 0.0, 1.0);
sFloraDensity[i] = std::min(tf, mf);
}
}
std::vector<Organism> Planet::fillFlora(int i, const std::vector<int>& nbr, uint32_t& rng) {
double dens = sFloraDensity[i];
int pts = (int)std::lround(cfg.bioFloraPoints * dens);
if (pts <= 0) return {};
Biome biome = cells[i].biome;
double temp = sTemp[i], moist = sMoist[i];
const auto& AR = biotaArchetypes();
std::vector<BiotaCandidate> cands;
for (int a = 0; a < (int)AR.size(); ++a) {
if (AR[a].kind != BiotaKind::Flora) continue;
double s = biotaSuitability(AR[a], biome, temp, moist);
if (s <= 0.0) continue;
double pres = 0.0; // regional consistency: same archetype next door
if (!nbr.empty()) {
int hit = 0;
for (int j : nbr)
for (const Organism& o : sBiota[j].flora) if (o.archetype == a) { ++hit; break; }
pres = (double)hit / nbr.size();
}
cands.push_back({ a, s * (1.0 + cfg.bioRegionBonus * pres) });
}
return biotaDraw(cands, biome, cfg.bioFloraSlots, pts, rng);
}

View File

@ -0,0 +1,53 @@
#include "Planet.hpp"
#include "PlanetBiota.hpp"
#include <algorithm>
#include <cmath>
// --- Biota: Funga (fungi) ----------------------------------------------------
// Fungi are NOT plants but share the environmental + slot/point machinery (see the
// user's note). Their rules differ from flora: moisture-led and organic-matter-led
// (they feed on dead/living flora), with a colder tolerance than plants -- so they
// thrive in moist forests/wetlands, persist in cold shade where flora thins, and
// fall to ~0 in hot dry deserts.
void Planet::computeFungaDensity() {
const int n = (int)cells.size();
if ((int)sFloraDensity.size() != n) computeFloraDensity();
sFungaDensity.assign(n, 0.0);
const double sea = cfg.seaLevel;
const double mRef = std::max(1e-6, cfg.bioFungaMoistRef);
const double tMin = cfg.bioFungaTempMin, w = std::clamp(cfg.bioFungaFloraWeight, 0.0, 1.0);
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || cells[i].biome == Biome::Ice) continue;
double mf = std::clamp(sMoist[i] / mRef, 0.0, 1.0);
// Cold-tolerant: full down to tMin, tapering below it.
double tf = (sTemp[i] >= tMin) ? 1.0 : std::clamp(1.0 - (tMin - sTemp[i]) / 10.0, 0.0, 1.0);
// Substrate: leans on flora (organic matter) but always some dead matter.
double organic = w * sFloraDensity[i] + (1.0 - w);
sFungaDensity[i] = std::clamp(std::min(mf, organic) * tf, 0.0, 1.0);
}
}
std::vector<Organism> Planet::fillFunga(int i, const std::vector<int>& nbr, uint32_t& rng) {
double dens = sFungaDensity[i];
int pts = (int)std::lround(cfg.bioFungaPoints * dens);
if (pts <= 0) return {};
Biome biome = cells[i].biome;
double temp = sTemp[i], moist = sMoist[i];
const auto& AR = biotaArchetypes();
std::vector<BiotaCandidate> cands;
for (int a = 0; a < (int)AR.size(); ++a) {
if (AR[a].kind != BiotaKind::Funga) continue;
double s = biotaSuitability(AR[a], biome, temp, moist);
if (s <= 0.0) continue;
double pres = 0.0;
if (!nbr.empty()) {
int hit = 0;
for (int j : nbr)
for (const Organism& o : sBiota[j].funga) if (o.archetype == a) { ++hit; break; }
pres = (double)hit / nbr.size();
}
cands.push_back({ a, s * (1.0 + cfg.bioRegionBonus * pres) });
}
return biotaDraw(cands, biome, cfg.bioFungaSlots, pts, rng);
}

View File

@ -29,9 +29,14 @@
D(biomeLakeMinDepth) \
D(climateOceanMoisture) D(climateRainEfficiency) D(climateOrographic) \
D(climateOroRefHeight) D(climateContinentality) \
D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \
D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \
D(bioFungaTempMin) D(bioRegionBonus) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \
U(seed)
// Write all config fields as `key = value` lines (no header). Shared by the text
@ -158,6 +163,16 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.climateOrographic, 0.0, 50.0, "climateOrographic"));
E(rng(cfg.climateOroRefHeight, 1.0, 1.0e5, "climateOroRefHeight"));
E(rng(cfg.climateContinentality, 0.0, 1.0, "climateContinentality"));
E(rng(cfg.bioVegTempMin, -40.0, 30.0, "bioVegTempMin"));
E(rng(cfg.bioVegTempOpt, -20.0, 50.0, "bioVegTempOpt"));
E(rng(cfg.bioVegMoistRef, 0.01, 1.0, "bioVegMoistRef"));
E(rng(cfg.bioFaunaProductivity, 0.0, 2.0, "bioFaunaProductivity"));
E(rng(cfg.bioCarnPreyMin, 0.0, 1.0, "bioCarnPreyMin"));
E(rng(cfg.bioCarnScale, 0.0, 5.0, "bioCarnScale"));
E(rng(cfg.bioFungaMoistRef, 0.01, 1.0, "bioFungaMoistRef"));
E(rng(cfg.bioFungaFloraWeight, 0.0, 1.0, "bioFungaFloraWeight"));
E(rng(cfg.bioFungaTempMin, -50.0, 20.0, "bioFungaTempMin"));
E(rng(cfg.bioRegionBonus, 0.0, 10.0, "bioRegionBonus"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -169,6 +184,12 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.seaLevelEvery, 1, 100000, "seaLevelEvery"));
E(irng(cfg.climateWindPasses, 1, 1000, "climateWindPasses"));
E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
E(irng(cfg.bioFloraPoints, 1, 100000, "bioFloraPoints"));
E(irng(cfg.bioFaunaPoints, 1, 100000, "bioFaunaPoints"));
E(irng(cfg.bioFungaPoints, 1, 100000, "bioFungaPoints"));
if (cfg.oceanBase >= cfg.continentBase)
bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
@ -227,9 +248,18 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, sPrevCount);
writeVec(os, sStaleStreak);
writeVec(os, sFreePlateIds);
// v7: discrete biota population (sBiota). A flag byte gates the block so a
// not-yet-populated world stays compact; otherwise three Organism lists per cell.
uint8_t hasBio = sHasBiota ? 1 : 0; writePod(os, hasBio);
if (hasBio) {
uint64_t nb = sBiota.size(); writePod(os, nb);
for (const CellBiota& cb : sBiota) {
writeVec(os, cb.flora); writeVec(os, cb.fauna); writeVec(os, cb.funga);
}
}
}
bool Planet::readState(std::istream& is, bool hasBiome) {
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota) {
// Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their
// current defaults. The length guard rejects pre-v6 (raw-POD-config) saves.
@ -259,5 +289,20 @@ bool Planet::readState(std::istream& is, bool hasBiome) {
readVec(is, sStaleStreak);
readVec(is, sFreePlateIds);
if (!hasBiome) classifyBiomes(); // old (v3) save: reclassify from loaded state
// v7: discrete biota population. buildGeometry() already sized sBiota empty;
// older saves (hasBiota=false) just keep the empty population (press L to fill).
sHasBiota = false;
if (hasBiota) {
uint8_t hasBio = 0; readPod(is, hasBio);
if (hasBio) {
uint64_t nb = 0; readPod(is, nb);
if (!is || nb != sBiota.size()) return false;
for (CellBiota& cb : sBiota) {
readVec(is, cb.flora); readVec(is, cb.fauna); readVec(is, cb.funga);
if (!cb.flora.empty() || !cb.fauna.empty() || !cb.funga.empty()) sHasBiota = true;
}
}
}
computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is;
}

View File

@ -187,4 +187,24 @@ struct PlanetConfig {
double climateContinentality = 0.05; // moisture lost per land cell crossed (dries interiors)
int climateWindPasses = 50; // moisture-advection iterations (steady state)
int climateMoistureSmooth = 12; // precipitation diffusion passes (wet/dry transition zones)
// --- Biota: flora / fauna / funga (see PlanetBiota.cpp + *Gen.cpp) -------
// Density scalars (derived each tick) drive the colour views; the discrete
// slot/point population (generated on demand, saved) draws archetypes by size.
double bioVegTempMin = -5.0; // C below which plants don't grow
double bioVegTempOpt = 15.0; // C at/above which temperature isn't limiting
double bioVegMoistRef = 0.5; // normalized moisture where water isn't limiting
double bioFaunaProductivity = 0.9; // herbivore capacity per unit vegetation
double bioCarnPreyMin = 0.30; // min local prey (fauna density) to support carnivores
double bioCarnScale = 1.0; // carnivore weight ramp above the prey threshold
double bioFungaMoistRef = 0.4; // normalized moisture where fungi aren't water-limited
double bioFungaFloraWeight = 0.6; // how much fungi lean on flora (organic matter) 0..1
double bioFungaTempMin = -15.0; // C above which fungi are not cold-limited (cold-tolerant)
double bioRegionBonus = 0.5; // weight boost for archetypes present in same-biome neighbours
int bioFloraSlots = 12; // max distinct flora per cell (point budget caps abundance)
int bioFaunaSlots = 10; // max distinct fauna per cell
int bioFungaSlots = 8; // max distinct funga per cell
int bioFloraPoints = 20; // flora point budget at full density (scaled by density)
int bioFaunaPoints = 16; // fauna point budget at full density
int bioFungaPoints = 14; // funga point budget at full density
};

154
test_biota.cpp Normal file
View File

@ -0,0 +1,154 @@
// Headless logic test for the Biota stage (flora / fauna / funga). No display.
//
// g++ -std=c++17 -O2 -Isrc/sim test_biota.cpp src/sim/IcoSphere.cpp \
// src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \
// src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \
// src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
// src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
// src/sim/PlanetIO.cpp -o /tmp/tb && /tmp/tb
//
// Verifies: density ranges + zeros on water/ice, fauna<=flora capacity, carnivores
// only where prey is sufficient, slot/point budgets respected, determinism + RNG
// isolation from tectonics, and save v7 round-trip (plus v6-style read leaving the
// population empty).
#include "Planet.hpp"
#include "PlanetBiota.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
static void settle(Planet& p, int maxSteps = 800) {
int run = 0;
for (int s = 0; s < maxSteps; ++s) {
double mc = p.step();
if (mc < 2.0) { if (++run >= 3) break; } else run = 0;
}
p.computeClimate();
p.classifyBiomes();
p.computeBiotaDensity();
}
static bool sameBiota(const std::vector<CellBiota>& a, const std::vector<CellBiota>& b) {
if (a.size() != b.size()) return false;
auto eq = [](const std::vector<Organism>& x, const std::vector<Organism>& y) {
if (x.size() != y.size()) return false;
for (size_t k = 0; k < x.size(); ++k)
if (x[k].archetype != y[k].archetype || x[k].biome != y[k].biome) return false;
return true;
};
for (size_t i = 0; i < a.size(); ++i)
if (!eq(a[i].flora, b[i].flora) || !eq(a[i].fauna, b[i].fauna) || !eq(a[i].funga, b[i].funga))
return false;
return true;
}
int main() {
PlanetConfig cfg; cfg.seed = 4242; cfg.subdivisions = 5;
Planet p; p.generate(cfg);
settle(p);
const int n = (int)p.cells.size();
const double sea = p.cfg.seaLevel;
// --- Density fields ------------------------------------------------------
const auto& fl = p.floraDensity(); const auto& fa = p.faunaDensity(); const auto& fu = p.fungaDensity();
check((int)fl.size() == n && (int)fa.size() == n && (int)fu.size() == n, "density fields sized n");
bool ranged = true, zerosOnWaterIce = true, faunaCap = true, faunaZero = true;
bool anyFloraHigh = false, anyFunga = false;
const double prod = p.cfg.bioFaunaProductivity;
for (int i = 0; i < n; ++i) {
for (double d : {fl[i], fa[i], fu[i]}) if (!(std::isfinite(d) && d >= 0.0 && d <= 1.0)) ranged = false;
bool waterIce = (p.cells[i].elevation <= sea) || (p.cells[i].biome == Biome::Ice);
if (waterIce && (fl[i] != 0.0 || fa[i] != 0.0 || fu[i] != 0.0)) zerosOnWaterIce = false;
if (fa[i] > fl[i] * prod + 1e-9) faunaCap = false; // fauna <= herbivore capacity
if (fl[i] == 0.0 && fa[i] != 0.0) faunaZero = false; // no animals without plants
if (p.cells[i].biome == Biome::Forest && fl[i] > 0.6) anyFloraHigh = true;
if (fu[i] > 0.05) anyFunga = true;
}
check(ranged, "all densities finite in [0,1]");
check(zerosOnWaterIce, "flora/fauna/funga = 0 on ocean & ice");
check(faunaCap, "fauna density <= flora * productivity");
check(faunaZero, "no fauna where flora is zero");
check(anyFloraHigh, "some forest cells are lush (flora > 0.6)");
check(anyFunga, "funga present somewhere");
// --- Discrete population: slots/points + carnivore gating ----------------
p.generateBiota();
check(p.biotaPopulated(), "generateBiota() populates a land world");
const auto& B = p.biota();
bool slotsOk = true, pointsOk = true, carnGated = true, onLand = true;
auto cost = [&](const std::vector<Organism>& v) { int s = 0; const auto& AR = biotaArchetypes();
for (auto& o : v) s += pointCost(AR[o.archetype].size); return s; };
for (int i = 0; i < n; ++i) {
const CellBiota& cb = B[i];
if (p.cells[i].elevation <= sea || p.cells[i].biome == Biome::Ice) {
if (!cb.flora.empty() || !cb.fauna.empty() || !cb.funga.empty()) onLand = false;
continue;
}
if ((int)cb.flora.size() > p.cfg.bioFloraSlots ||
(int)cb.fauna.size() > p.cfg.bioFaunaSlots ||
(int)cb.funga.size() > p.cfg.bioFungaSlots) slotsOk = false;
if (cost(cb.flora) > (int)std::lround(p.cfg.bioFloraPoints * fl[i]) ||
cost(cb.fauna) > (int)std::lround(p.cfg.bioFaunaPoints * fa[i]) ||
cost(cb.funga) > (int)std::lround(p.cfg.bioFungaPoints * fu[i])) pointsOk = false;
// Carnivore present => local prey (mean fauna density over i + neighbours) clears the threshold.
bool hasCarn = false;
for (const Organism& o : cb.fauna)
if (biotaArchetypes()[o.archetype].role == EcoRole::Carnivore) hasCarn = true;
if (hasCarn) {
double sum = fa[i]; int c = 1;
for (int j : p.cells[i].neighbors) { sum += fa[j]; ++c; }
if (sum / c <= p.cfg.bioCarnPreyMin) carnGated = false;
}
}
check(onLand, "no organisms on ocean/ice cells");
check(slotsOk, "per-cell organism count <= slot budget");
check(pointsOk, "per-cell point cost <= density-scaled point budget");
check(carnGated, "carnivores only where neighbourhood prey > bioCarnPreyMin");
// --- Determinism: same seed -> identical population ----------------------
std::vector<CellBiota> first = p.biota();
p.generateBiota();
check(sameBiota(first, p.biota()), "generateBiota() is deterministic (re-run identical)");
// --- RNG isolation: generating biota must not perturb tectonics ----------
{
Planet a; a.generate(cfg); settle(a); a.drifting = true;
Planet b; b.generate(cfg); settle(b); b.drifting = true;
b.generateBiota(); // only b generates biota
double dt = a.cflDtMy();
for (int k = 0; k < 5; ++k) { a.advect(dt); a.step(); a.erode(dt);
b.advect(dt); b.step(); b.erode(dt); }
bool identical = a.cells.size() == b.cells.size();
for (size_t i = 0; identical && i < a.cells.size(); ++i)
if (a.cells[i].elevation != b.cells[i].elevation || a.cells[i].plateId != b.cells[i].plateId)
identical = false;
check(identical, "biota generation does not change tectonic evolution (separate RNG)");
}
// --- Save v7 round-trip + v6-style read (empty population) ---------------
{
std::ostringstream os(std::ios::binary);
p.writeState(os);
std::string blob = os.str();
Planet q; std::istringstream is(blob, std::ios::binary);
bool ok = q.readState(is, /*hasBiome*/true, /*hasBiota*/true);
check(ok && q.biotaPopulated() && sameBiota(p.biota(), q.biota()), "save v7 round-trips the biota population");
Planet r; std::istringstream is2(blob, std::ios::binary);
bool ok2 = r.readState(is2, /*hasBiome*/true, /*hasBiota*/false); // old (pre-v7) read path
check(ok2 && !r.biotaPopulated(), "pre-v7 read leaves population empty (loads fine)");
}
std::printf("\n%s (%d failure%s)\n", failures ? "FAILURES" : "ALL PASS",
failures, failures == 1 ? "" : "s");
return failures ? 1 : 0;
}