From 53e371bb2f2c50946421b74d25549b3ca39e2b9b Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Thu, 25 Jun 2026 13:56:03 +0200 Subject: [PATCH] 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 --- BUILD.md | 31 ++- CLAUDE.md | 72 +++++-- CMakeLists.txt | 4 + docs/design-notes.md | 35 +++- docs/fauna-flora-plan.md | 114 ++++------- docs/fauna_generation_plan.md | 91 +++++++++ docs/flora_generation_plan.md | 368 ++++++++++++++++++++++++++++++++++ src/render/Colors.cpp | 30 +++ src/render/Colors.hpp | 8 +- src/render/Panels.cpp | 67 ++++++- src/render/Viewer.cpp | 9 +- src/render/Viewer.hpp | 2 +- src/render/ViewerInput.cpp | 11 +- src/render/ViewerRender.cpp | 6 +- src/sim/Planet.cpp | 3 + src/sim/Planet.hpp | 35 +++- src/sim/PlanetBiota.cpp | 244 ++++++++++++++++++++++ src/sim/PlanetBiota.hpp | 81 ++++++++ src/sim/PlanetFaunaGen.cpp | 66 ++++++ src/sim/PlanetFloraGen.cpp | 48 +++++ src/sim/PlanetFungiGen.cpp | 53 +++++ src/sim/PlanetIO.cpp | 47 ++++- src/sim/PlanetTypes.hpp | 20 ++ test_biota.cpp | 154 ++++++++++++++ 24 files changed, 1498 insertions(+), 101 deletions(-) create mode 100644 docs/fauna_generation_plan.md create mode 100644 docs/flora_generation_plan.md create mode 100644 src/sim/PlanetBiota.cpp create mode 100644 src/sim/PlanetBiota.hpp create mode 100644 src/sim/PlanetFaunaGen.cpp create mode 100644 src/sim/PlanetFloraGen.cpp create mode 100644 src/sim/PlanetFungiGen.cpp create mode 100644 test_biota.cpp diff --git a/BUILD.md b/BUILD.md index d9e4976..895f071 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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 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(). diff --git a/CLAUDE.md b/CLAUDE.md index 56c64d7..3ed65ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index f5dc2e3..5d3e50d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/design-notes.md b/docs/design-notes.md index f39eeea..560d3a3 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -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). diff --git a/docs/fauna-flora-plan.md b/docs/fauna-flora-plan.md index ffe2bb6..b1ca37e 100644 --- a/docs/fauna-flora-plan.md +++ b/docs/fauna-flora-plan.md @@ -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& vegetation()/herbivores()/carnivores() const`. Add scratch - members `std::vector 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((T−bioVegTempMin)/(bioVegTempOpt−bioVegTempMin),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 + (1−w)) · 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"*. diff --git a/docs/fauna_generation_plan.md b/docs/fauna_generation_plan.md new file mode 100644 index 0000000..85630ae --- /dev/null +++ b/docs/fauna_generation_plan.md @@ -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. diff --git a/docs/flora_generation_plan.md b/docs/flora_generation_plan.md new file mode 100644 index 0000000..643009f --- /dev/null +++ b/docs/flora_generation_plan.md @@ -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. diff --git a/src/render/Colors.cpp b/src/render/Colors.cpp index 907aebf..8edeacd 100644 --- a/src/render/Colors.cpp +++ b/src/render/Colors.cpp @@ -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 diff --git a/src/render/Colors.hpp b/src/render/Colors.hpp index 0f942aa..6971e43 100644 --- a/src/render/Colors.hpp +++ b/src/render/Colors.hpp @@ -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); diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index 9aed36c..26accb3 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -1,10 +1,34 @@ #include "Panels.hpp" #include "Colors.hpp" // elevationColor (subtile grid) +#include "PlanetBiota.hpp" // organismName / sizeName / roleName #include "Projection.hpp" // dirToLonLat #include #include #include #include +#include + +// 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 cellInfo(const Planet& p, int i, double elev, double age) { @@ -32,6 +56,39 @@ static std::vector 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& v) { + if (v.empty()) return; + std::vector> 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(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& 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; } } diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 48c7ce5..2e0c851 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -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& temp = planet.temperature(); const std::vector& moist = planet.moisture(); // 0..1, already robustly normalized + const std::vector& flora = planet.floraDensity(); + const std::vector& fauna = planet.faunaDensity(); + const std::vector& 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(&dr), sizeof dr); if (ver >= 3) is.read(reinterpret_cast(&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 diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 1035fa6..f20d18d 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -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) diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index c6d7406..3565577 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -103,7 +103,10 @@ void Viewer::handleInput() { if (IsKeyPressed(KEY_FOUR)) { mode = ColorMode::Crust; recolor(); } 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_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) diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index bce9a9f..5931ad5 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -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; diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index b71112e..b922736 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -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() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 2d45be1..2d3de2b 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -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 #include #include @@ -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& floraDensity() const { return sFloraDensity; } + const std::vector& faunaDensity() const { return sFaunaDensity; } + const std::vector& fungaDensity() const { return sFungaDensity; } + const std::vector& biota() const { return sBiota; } + // Derived hydrology fields (recomputed each route; not saved). Empty until // the first computeHydrology()/hydrology() call. const std::vector& 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 fillFlora(int i, const std::vector& nbr, uint32_t& rng); + std::vector fillFauna(int i, const std::vector& nbr, uint32_t& rng); + std::vector fillFunga(int i, const std::vector& 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 sPrevCount; // per-plate cell count at the previous check @@ -129,6 +156,12 @@ private: std::vector sTemp, sPrecip, sMoist; std::vector sWind; std::vector 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 sFloraDensity, sFaunaDensity, sFungaDensity; + std::vector sBiota; + bool sHasBiota = false; }; // Human-editable config file (key = value text). All PlanetConfig input diff --git a/src/sim/PlanetBiota.cpp b/src/sim/PlanetBiota.cpp new file mode 100644 index 0000000..47d130f --- /dev/null +++ b/src/sim/PlanetBiota.cpp @@ -0,0 +1,244 @@ +#include "Planet.hpp" +#include "PlanetBiota.hpp" +#include +#include +#include + +// --- 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& biotaArchetypes() { + using K = BiotaKind; using R = EcoRole; using S = SizeClass; using B = Biome; + static const std::vector T = [] { + auto M = [](std::initializer_list bs) { + uint32_t m = 0; for (B b : bs) m |= 1u << (unsigned)b; return m; + }; + std::vector 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 biotaDraw(const std::vector& cands, Biome biome, + int maxSlots, int maxPoints, uint32_t& rng) { + std::vector 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 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 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; + } +} diff --git a/src/sim/PlanetBiota.hpp b/src/sim/PlanetBiota.hpp new file mode 100644 index 0000000..8f9c00e --- /dev/null +++ b/src/sim/PlanetBiota.hpp @@ -0,0 +1,81 @@ +#pragma once +#include "PlanetTypes.hpp" // Biome +#include +#include +#include + +// --- 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 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& 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 biotaDraw(const std::vector& 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); } diff --git a/src/sim/PlanetFaunaGen.cpp b/src/sim/PlanetFaunaGen.cpp new file mode 100644 index 0000000..a1dbfe9 --- /dev/null +++ b/src/sim/PlanetFaunaGen.cpp @@ -0,0 +1,66 @@ +#include "Planet.hpp" +#include "PlanetBiota.hpp" +#include +#include + +// --- 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 Planet::fillFauna(int i, const std::vector& 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 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); +} diff --git a/src/sim/PlanetFloraGen.cpp b/src/sim/PlanetFloraGen.cpp new file mode 100644 index 0000000..31daba5 --- /dev/null +++ b/src/sim/PlanetFloraGen.cpp @@ -0,0 +1,48 @@ +#include "Planet.hpp" +#include "PlanetBiota.hpp" +#include +#include + +// --- 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 Planet::fillFlora(int i, const std::vector& 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 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); +} diff --git a/src/sim/PlanetFungiGen.cpp b/src/sim/PlanetFungiGen.cpp new file mode 100644 index 0000000..ea802b7 --- /dev/null +++ b/src/sim/PlanetFungiGen.cpp @@ -0,0 +1,53 @@ +#include "Planet.hpp" +#include "PlanetBiota.hpp" +#include +#include + +// --- 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 Planet::fillFunga(int i, const std::vector& 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 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); +} diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index ce9919c..122205f 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -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; } diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 15cb07f..54669e3 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -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 }; diff --git a/test_biota.cpp b/test_biota.cpp new file mode 100644 index 0000000..ed90f8d --- /dev/null +++ b/test_biota.cpp @@ -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 +#include +#include +#include + +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& a, const std::vector& b) { + if (a.size() != b.size()) return false; + auto eq = [](const std::vector& x, const std::vector& 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& 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 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; +}