From b005969ee1e86681eaea8933fc0ce76978584187 Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Mon, 29 Jun 2026 12:04:53 +0200 Subject: [PATCH] Add marine flora & fauna (life in the ocean) The biota density + population layers were hard-gated on elevation<=sea, so the ocean was barren in the flora/fauna views and held no organisms. - Density: ocean cells (not under polar Ice) get a marine primary productivity in computeFloraDensity -- base + (1-base)*max(shelf, coast), where shelf is shallowness (light) and coast is a BFS ring-distance from land (nutrients). Rich shelves/coasts, lower open ocean, zero under ice; sMoist (a land field) is not used at sea. computeFaunaDensity now skips only Ice, so marine fauna = flora*productivity with the existing carnivore prey-gate clustering big predators on rich shelves. Funga stays land-only. - Population: append Ocean-masked archetypes (Kelp/Seagrass/Phytoplankton; Forage fish/Reef fish/Shark/Baleen whale/Seal/Squid; moistMin=0, SST-zoned). generateBiota fills ocean cells (skip Ice; no marine funga). fillFlora/ fillFauna unchanged -- their biome-mask filter zones marine vs terrestrial. Append-only, so v7 saves are unaffected. - Render: distinct marine ramps (marineFloraColor blue->teal/green bloom, marineFaunaColor blue->cyan->warm) for water cells in the 8/9 views; land ramps + funga view unchanged. - Config: bioMarineBase/bioMarineShelfDepth/bioMarineCoastRings (self-describing config -> no save bump). - test_biota.cpp updated: zero life under ice, marine flora/fauna present at sea + populate ocean tiles, funga 0 on water; capacity/carnivore-gate/budgets/ determinism still hold. All five headless suites pass; GUI build clean. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 36 ++++++++++++++++++++++++----- docs/design-notes.md | 22 ++++++++++++++++-- src/render/Colors.cpp | 15 +++++++++++++ src/render/Colors.hpp | 5 +++++ src/render/Viewer.cpp | 6 +++-- src/sim/PlanetBiota.cpp | 29 ++++++++++++++++++++++-- src/sim/PlanetFaunaGen.cpp | 8 +++---- src/sim/PlanetFloraGen.cpp | 43 ++++++++++++++++++++++++++++------- src/sim/PlanetIO.cpp | 7 ++++-- src/sim/PlanetTypes.hpp | 3 +++ test_biota.cpp | 46 +++++++++++++++++++++++++------------- 11 files changed, 180 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 142d6a8..61409f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -319,8 +319,8 @@ Working and verified (logic tested headless): (`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 + cell draws broad **archetypes** from a comprehensive table (`biotaArchetypes()`, 49 entries + across Flora/Fauna/Funga incl. marine, 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 @@ -334,6 +334,27 @@ Working and verified (logic tested headless): 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`). +- **Biota — marine flora & fauna (life in the ocean):** the biota layers used to be 0 on every + water cell (a hard `elevation<=sea` gate + no Ocean-masked archetypes), so the sea read as + barren. Now ocean cells (not under polar `Ice`) get a **marine primary productivity** in + `computeFloraDensity`: `base + (1-base)·max(shelf, coast)` where `shelf` = shallowness + (`1 - depth/bioMarineShelfDepth`, light to the photic floor) and `coast` = a multi-source BFS + **ring-distance from land** (nutrient runoff; mirrors the continentality BFS, seeded from land + not ocean) — so productivity is rich on sunlit shelves/coasts, lower in the deep open ocean, + zero only under ice; `sMoist` (a *land* rainfall field) is **not** used at sea. `computeFaunaDensity` + now skips only `Ice` (was all water) so marine fauna = `flora·productivity`, with the existing + carnivore prey-gate clustering sharks/seals/squid on rich shelves. **Funga stays land-only.** + `generateBiota` populates ocean cells too (skip `Ice`; no marine funga) — `fillFlora`/`fillFauna` + are unchanged because their biome-mask filter draws only the **new Ocean-masked archetypes** + appended to `biotaArchetypes()`: marine flora **Kelp / Seagrass / Phytoplankton** and marine fauna + **Forage fish / Reef fish / Shark / Baleen whale / Seal / Squid** (all `moistMin=0`, SST-zoned; + append-only so v7 saves are unaffected — old saves just lack them until `L`). The flora/fauna + color views (`8`/`9`) render ocean on a **distinct marine ramp** (`marineFloraColor` deep + blue→teal/green bloom, `marineFaunaColor` deep blue→cyan→warm) so the sea still reads as sea; + land ramps + the funga view unchanged. New `bioMarineBase`/`bioMarineShelfDepth`/ + `bioMarineCoastRings` config knobs (self-describing config → **no save bump**). `test_biota.cpp` + updated: zero life under ice, marine flora/fauna present at sea + populate ocean tiles, funga 0 + on water, capacity/carnivore-gate/budgets/determinism still hold. - **Live World — clock + day/night + live seasons + snow line:** the first **Live World** stage (the slow real-time arc after World Creation). Engine (`src/sim/PlanetLive.cpp`, raylib-free, derived/not-saved): `computeInsolation(dayOfYear01, timeOfDay01)` → `sInsolation` (0..1 cosine @@ -703,12 +724,17 @@ triangles (plates are fixed in phase 1). - 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); + `bioFungaFloraWeight`/`bioFungaTempMin` (funga moisture/organic-matter/cold rules); **marine + flora/fauna**: `bioMarineBase` (0.15, open-ocean baseline density far from land), `bioMarineShelfDepth` + (2500 m, depth over which shelf/light productivity fades to the base), `bioMarineCoastRings` + (3, ocean rings from land over which coastal-nutrient richness fades to the base — lower = a + tighter coastal band, higher = life further offshore); 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). + homogeneity vs variety). To add organisms (incl. marine — give them a `B::Ocean` biome mask + and `moistMin=0`), append to `biotaArchetypes()` in PlanetBiota.cpp (append-only — indices are + serialized in v7 saves). - **Live World (`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp`, `planet.cfg`):** `dayLengthHours` (24) sets the day/night period (and the `d/s` rate unit), `yearLengthDays` (365.25) the season period; `axialTilt` drives the seasonal declination (0 = no day/night diff --git a/docs/design-notes.md b/docs/design-notes.md index 79ed582..7de3293 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -84,8 +84,10 @@ fine with an empty population (`readState(is, hasBiome, hasBiota)`; `hasBiota = 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 ++ cold-tolerant. All three are 0 under polar `Ice`; funga is also 0 on water, while flora/fauna +extend into the ocean as marine productivity (see below). (2) On-demand discrete **population** +`generateBiota()`: each land **or ocean** cell draws broad archetypes from the comprehensive +append-only `biotaArchetypes()` table into a per-kind slot cap + density-scaled point budget (size → cost Tiny=1…Huge=5), weighted by biome/climate suitability and a **regional bonus** for archetypes already in same-biome neighbours (single index-ordered pass → homogeneous regions, boundary variety). Organisms are @@ -95,6 +97,22 @@ get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seede `Planet::rngState`) so populating biota never perturbs tectonic determinism — asserted in `test_biota.cpp`. The archetype table is **append-only** (indices are serialized in v7 saves). +**Marine flora & fauna (life in the ocean).** Funga stays land-only, but flora and fauna now +extend over water (the old hard `elevation<=sea` gate left the sea barren). Ocean cells (not under +polar `Ice`) get a marine primary productivity in `computeFloraDensity`: +`base + (1-base)·max(shelf, coast)`, where `shelf = clamp(1 - depth/bioMarineShelfDepth)` (light to +the photic floor) and `coast = clamp(1 - ringDistFromLand/bioMarineCoastRings)` (land-runoff +nutrients, a multi-source BFS ring-distance seeded from land — the continentality BFS mirrored). +`sMoist` is a land rainfall field and is not used at sea. `computeFaunaDensity` now skips only `Ice`, +so marine fauna = `flora·productivity` and the carnivore prey-gate clusters sharks/seals/squid on +rich shelves. `generateBiota` fills ocean cells too (skip `Ice`; no marine funga); `fillFlora`/ +`fillFauna` are unchanged because their biome-mask filter draws only the **Ocean-masked** archetypes +appended to the table (Kelp/Seagrass/Phytoplankton; Forage fish/Reef fish/Shark/Baleen whale/Seal/ +Squid — all `moistMin=0`, SST-zoned). The flora/fauna colour views render ocean on a distinct +marine ramp (`marineFloraColor`/`marineFaunaColor`). Knobs: `bioMarineBase`/`bioMarineShelfDepth`/ +`bioMarineCoastRings`. No save bump (densities derived; archetypes append-only; config +self-describing). + ## Climate + biome model (derived, not saved) `computeClimate()` builds two derived per-cell fields: diff --git a/src/render/Colors.cpp b/src/render/Colors.cpp index 6255606..ecc46ce 100644 --- a/src/render/Colors.cpp +++ b/src/render/Colors.cpp @@ -124,6 +124,21 @@ 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); } +Color marineFloraColor(double d01) { // deep ocean blue -> bright teal/green bloom + static const unsigned char lo[3] = { 18, 45, 80 }, hi[3] = { 60, 215, 160 }; + return ramp2(d01, lo, hi); +} +Color marineFaunaColor(double d01) { // deep blue -> cyan -> warm (rich shelves) + double t = std::clamp(d01, 0.0, 1.0); + static const unsigned char key[3][3] = { + { 18, 45, 80 }, // 0.0 deep blue + { 50, 175, 200 }, // 0.5 cyan + { 235, 195, 90 }, // 1.0 warm/gold + }; + 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 }; +} // Temperature ramp over ~[-40, 40] C: deep blue -> cyan -> green -> yellow -> red. Color tempColor(double celsius) { diff --git a/src/render/Colors.hpp b/src/render/Colors.hpp index f729e98..5585eeb 100644 --- a/src/render/Colors.hpp +++ b/src/render/Colors.hpp @@ -33,3 +33,8 @@ Color tideColor(double level, double range); Color floraColor(double d01); Color faunaColor(double d01); Color fungaColor(double d01); +// Marine biota density ramps (0..1) -- a distinct sea palette so ocean reads as +// ocean: marine flora deep blue->bright teal/green bloom, marine fauna deep +// blue->cyan->warm. Used for water cells in the flora/fauna views. +Color marineFloraColor(double d01); +Color marineFaunaColor(double d01); diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 96554dd..9be7821 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -125,8 +125,10 @@ void Viewer::recolor() { case ColorMode::Seasonality: vcolors[i] = (summer.empty()||winter.empty()) ? Color{90,90,90,255} : seasonColor(summer[i] - winter[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::FloraDensity: vcolors[i] = flora.empty() ? Color{90,90,90,255} + : (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFloraColor(flora[i]) : floraColor(flora[i])); break; + case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255} + : (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFaunaColor(fauna[i]) : 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); } diff --git a/src/sim/PlanetBiota.cpp b/src/sim/PlanetBiota.cpp index 47d130f..1829e14 100644 --- a/src/sim/PlanetBiota.cpp +++ b/src/sim/PlanetBiota.cpp @@ -106,6 +106,28 @@ const std::vector& biotaArchetypes() { 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 }); + // ---- Marine flora (primary producers) -- Ocean-masked, moisture-independent + // (sMoist is a land rainfall field, so moistMin = 0; SST zones them) ---- + a.push_back({ "Kelp", K::Flora, R::Aquatic, "Phaeophyceae", "Laminariales", "Laminariaceae", + S::Big, M({B::Ocean}), -2.0, 20.0, 0.0, false }); // cold-temperate forests + a.push_back({ "Seagrass", K::Flora, R::Aquatic, "Liliopsida", "Alismatales", "Zosteraceae", + S::Small, M({B::Ocean, B::Beach}), 10.0, 34.0, 0.0, false }); // warm shallows + a.push_back({ "Phytoplankton", K::Flora, R::Aquatic, "Bacillariophyceae", "Naviculales", "Naviculaceae", + S::Tiny, M({B::Ocean}), -2.0, 34.0, 0.0, false }); // ubiquitous drifting base + // ---- Marine fauna -- Ocean-masked. Big predators (shark/seal/squid) are + // gated on local prey in fillFauna, so they cluster on rich shelves ---- + a.push_back({ "Forage fish", K::Fauna, R::Herbivore, "Actinopterygii", "Clupeiformes", "Clupeidae", + S::Tiny, M({B::Ocean}), -2.0, 32.0, 0.0, false }); // plankton-grazing shoals + a.push_back({ "Reef fish", K::Fauna, R::Omnivore, "Actinopterygii", "Perciformes", "Serranidae", + S::Small, M({B::Ocean}), 4.0, 34.0, 0.0, false }); + a.push_back({ "Shark", K::Fauna, R::Carnivore, "Chondrichthyes", "Carcharhiniformes", "Carcharhinidae", + S::Big, M({B::Ocean}), 2.0, 32.0, 0.0, false }); + a.push_back({ "Baleen whale", K::Fauna, R::Herbivore, "Mammalia", "Cetacea", "Balaenopteridae", + S::Huge, M({B::Ocean}), -2.0, 30.0, 0.0, false }); // filter-feeder on plankton + a.push_back({ "Seal", K::Fauna, R::Carnivore, "Mammalia", "Carnivora", "Phocidae", + S::Medium, M({B::Ocean}), -12.0, 18.0, 0.0, false }); // cold-water pinniped + a.push_back({ "Squid", K::Fauna, R::Carnivore, "Cephalopoda", "Myopsida", "Loliginidae", + S::Small, M({B::Ocean}), 2.0, 30.0, 0.0, false }); return a; }(); return T; @@ -230,13 +252,16 @@ void Planet::generateBiota() { 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; } + if (cells[i].biome == Biome::Ice) { done[i] = 1; continue; } // frozen poles/peaks: empty + bool ocean = cells[i].elevation <= sea; 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); + // On ocean cells fillFlora/fillFauna draw only Ocean-masked (marine) + // archetypes via the biome-mask filter; funga stays land-only (no marine fungi). sBiota[i].flora = fillFlora(i, nbr, rng); sBiota[i].fauna = fillFauna(i, nbr, rng); - sBiota[i].funga = fillFunga(i, nbr, rng); + sBiota[i].funga = ocean ? std::vector{} : 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/PlanetFaunaGen.cpp b/src/sim/PlanetFaunaGen.cpp index a1dbfe9..a1b6162 100644 --- a/src/sim/PlanetFaunaGen.cpp +++ b/src/sim/PlanetFaunaGen.cpp @@ -13,12 +13,12 @@ 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). + if (cells[i].biome == Biome::Ice) continue; // ice: no animals (land or frozen sea) + // Animal richness tracks primary productivity (herbivores/grazers are the + // bulk of the biomass) -- on land that is plant flora, at sea the marine + // flora (plankton/algae) computed by computeFloraDensity for ocean cells. sFaunaDensity[i] = std::clamp(sFloraDensity[i] * prod, 0.0, 1.0); } } diff --git a/src/sim/PlanetFloraGen.cpp b/src/sim/PlanetFloraGen.cpp index 31daba5..060039b 100644 --- a/src/sim/PlanetFloraGen.cpp +++ b/src/sim/PlanetFloraGen.cpp @@ -3,10 +3,12 @@ #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. +// --- Biota: Flora (plants + marine primary producers) ------------------------ +// Land: density = an NPP-style Liebig minimum of a temperature factor and a +// moisture factor (lush warm-wet, ~0 in desert/alpine). Ocean: marine primary +// productivity (phytoplankton/algae/kelp/seagrass) -- rich on sunlit shelves and +// near land (nutrients), lower in the deep open ocean, 0 only under polar Ice. +// fillFlora() draws archetypes suited to the cell's biome/climate into its budget. void Planet::computeFloraDensity() { const int n = (int)cells.size(); @@ -15,11 +17,36 @@ void Planet::computeFloraDensity() { const double sea = cfg.seaLevel; const double tMin = cfg.bioVegTempMin, tOpt = cfg.bioVegTempOpt; const double mRef = std::max(1e-6, cfg.bioVegMoistRef); + // Coastal proximity for the ocean: multi-source BFS ring distance from LAND + // cells outward over the fixed neighbour graph (deterministic, order-independent, + // O(n)). Near-land seas get a land-runoff nutrient boost (cf. continentality in + // PlanetClimate.cpp, seeded from ocean -- here we seed from land). + const int coastRings = std::max(1, cfg.bioMarineCoastRings); + std::vector landDist(n, -1), frontier, next; + for (int i = 0; i < n; ++i) + if (cells[i].elevation > sea) { landDist[i] = 0; frontier.push_back(i); } + for (int r = 1; r <= coastRings && !frontier.empty(); ++r) { + next.clear(); + for (int i : frontier) + for (int nb : cells[i].neighbors) + if (landDist[nb] < 0) { landDist[nb] = r; next.push_back(nb); } + frontier.swap(next); + } + const double mBase = std::clamp(cfg.bioMarineBase, 0.0, 1.0); + const double shelfDepth = std::max(1.0, cfg.bioMarineShelfDepth); 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); + if (cells[i].biome == Biome::Ice) continue; // frozen poles/peaks: no life + if (cells[i].elevation > sea) { // land: temp+moisture Liebig minimum + 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); + } else { // ocean: shelf (light) + coastal nutrients + double depth = sea - cells[i].elevation; + double shelf = std::clamp(1.0 - depth / shelfDepth, 0.0, 1.0); + double coast = (landDist[i] < 0) ? 0.0 + : std::clamp(1.0 - (double)landDist[i] / coastRings, 0.0, 1.0); + sFloraDensity[i] = std::clamp(mBase + (1.0 - mBase) * std::max(shelf, coast), 0.0, 1.0); + } } } diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 1183a21..7c4b2e2 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -34,7 +34,7 @@ D(seasonAmpMax) D(seasonLatExp) D(seasonOceanFactor) \ D(bioVegTempMin) D(bioVegTempOpt) D(bioVegMoistRef) D(bioFaunaProductivity) \ D(bioCarnPreyMin) D(bioCarnScale) D(bioFungaMoistRef) D(bioFungaFloraWeight) \ - D(bioFungaTempMin) D(bioRegionBonus) \ + D(bioFungaTempMin) D(bioRegionBonus) D(bioMarineBase) D(bioMarineShelfDepth) \ D(dayLengthHours) D(yearLengthDays) D(snowTemp) D(seaIceTemp) \ D(tideAmplitude) D(tideSunFactor) \ D(weatherEvapRate) D(weatherWindKmh) D(weatherSatBase) D(weatherSatTempCoef) \ @@ -46,7 +46,7 @@ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ - I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) \ + I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ U(seed) // Write all config fields as `key = value` lines (no header). Shared by the text @@ -191,6 +191,8 @@ std::string validateConfig(const PlanetConfig& cfg) { 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(rng(cfg.bioMarineBase, 0.0, 1.0, "bioMarineBase")); + E(rng(cfg.bioMarineShelfDepth, 1.0, 11000.0, "bioMarineShelfDepth")); E(rng(cfg.dayLengthHours, 0.1, 1.0e5, "dayLengthHours")); E(rng(cfg.yearLengthDays, 1.0, 1.0e7, "yearLengthDays")); E(rng(cfg.snowTemp, -60.0, 30.0, "snowTemp")); @@ -232,6 +234,7 @@ std::string validateConfig(const PlanetConfig& cfg) { E(irng(cfg.bioFloraPoints, 1, 100000, "bioFloraPoints")); E(irng(cfg.bioFaunaPoints, 1, 100000, "bioFaunaPoints")); E(irng(cfg.bioFungaPoints, 1, 100000, "bioFungaPoints")); + E(irng(cfg.bioMarineCoastRings, 1, 100, "bioMarineCoastRings")); if (cfg.oceanBase >= cfg.continentBase) bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)"); diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 14821f1..65c0451 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -263,6 +263,9 @@ struct PlanetConfig { 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 + double bioMarineBase = 0.15; // open-ocean baseline marine flora density (deep, far from land) + double bioMarineShelfDepth = 2500.0; // m of depth over which shelf (light) productivity fades to base + int bioMarineCoastRings = 3; // ocean rings from land over which coastal richness fades to base 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 diff --git a/test_biota.cpp b/test_biota.cpp index ed90f8d..82c09ef 100644 --- a/test_biota.cpp +++ b/test_biota.cpp @@ -8,10 +8,10 @@ // 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). +// Verifies: density ranges, zero life under ice + marine flora/fauna present in the +// ocean (funga land-only), 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" @@ -61,20 +61,29 @@ int main() { // --- 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; + bool ranged = true, iceZero = true, oceanFungaZero = true, faunaCap = true, faunaZero = true; + bool anyFloraHigh = false, anyFunga = false, anyMarineFlora = false, anyMarineFauna = 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 + bool ice = (p.cells[i].biome == Biome::Ice); + bool ocean = (p.cells[i].elevation <= sea); + if (ice && (fl[i] != 0.0 || fa[i] != 0.0 || fu[i] != 0.0)) iceZero = false; // no life under ice + if (ocean && !ice) { // marine flora/fauna allowed; funga land-only + if (fu[i] != 0.0) oceanFungaZero = false; + if (fl[i] > 0.0) anyMarineFlora = true; + if (fa[i] > 0.0) anyMarineFauna = true; + } + if (fa[i] > fl[i] * prod + 1e-9) faunaCap = false; // fauna <= herbivore capacity (land + sea) + if (fl[i] == 0.0 && fa[i] != 0.0) faunaZero = false; // no animals without producers 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(iceZero, "flora/fauna/funga = 0 on ice cells"); + check(oceanFungaZero, "funga = 0 on ocean (marine fungi out of scope)"); + check(anyMarineFlora, "marine flora present in the ocean"); + check(anyMarineFauna, "marine fauna present in the ocean"); check(faunaCap, "fauna density <= flora * productivity"); check(faunaZero, "no fauna where flora is zero"); check(anyFloraHigh, "some forest cells are lush (flora > 0.6)"); @@ -84,15 +93,20 @@ int main() { p.generateBiota(); check(p.biotaPopulated(), "generateBiota() populates a land world"); const auto& B = p.biota(); - bool slotsOk = true, pointsOk = true, carnGated = true, onLand = true; + bool slotsOk = true, pointsOk = true, carnGated = true, iceEmpty = true, oceanFungaEmpty = true; + bool anyMarineOrg = false; 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; + if (p.cells[i].biome == Biome::Ice) { // ice (land or frozen sea): empty + if (!cb.flora.empty() || !cb.fauna.empty() || !cb.funga.empty()) iceEmpty = false; continue; } + if (p.cells[i].elevation <= sea) { // ocean: marine flora/fauna, no funga + if (!cb.funga.empty()) oceanFungaEmpty = false; + if (!cb.flora.empty() || !cb.fauna.empty()) anyMarineOrg = true; + } if ((int)cb.flora.size() > p.cfg.bioFloraSlots || (int)cb.fauna.size() > p.cfg.bioFaunaSlots || (int)cb.funga.size() > p.cfg.bioFungaSlots) slotsOk = false; @@ -109,7 +123,9 @@ int main() { if (sum / c <= p.cfg.bioCarnPreyMin) carnGated = false; } } - check(onLand, "no organisms on ocean/ice cells"); + check(iceEmpty, "no organisms on ice cells"); + check(oceanFungaEmpty, "no funga on ocean cells (marine fungi out of scope)"); + check(anyMarineOrg, "marine flora/fauna populate the ocean"); 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");