planetsim/test_biota.cpp
Jonas Reith b005969ee1 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 <noreply@anthropic.com>
2026-06-29 12:04:53 +02:00

171 lines
8.6 KiB
C++

// 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, 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"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
static void settle(Planet& p, int maxSteps = 800) {
int run = 0;
for (int s = 0; s < maxSteps; ++s) {
double mc = p.step();
if (mc < 2.0) { if (++run >= 3) break; } else run = 0;
}
p.computeClimate();
p.classifyBiomes();
p.computeBiotaDensity();
}
static bool sameBiota(const std::vector<CellBiota>& a, const std::vector<CellBiota>& b) {
if (a.size() != b.size()) return false;
auto eq = [](const std::vector<Organism>& x, const std::vector<Organism>& y) {
if (x.size() != y.size()) return false;
for (size_t k = 0; k < x.size(); ++k)
if (x[k].archetype != y[k].archetype || x[k].biome != y[k].biome) return false;
return true;
};
for (size_t i = 0; i < a.size(); ++i)
if (!eq(a[i].flora, b[i].flora) || !eq(a[i].fauna, b[i].fauna) || !eq(a[i].funga, b[i].funga))
return false;
return true;
}
int main() {
PlanetConfig cfg; cfg.seed = 4242; cfg.subdivisions = 5;
Planet p; p.generate(cfg);
settle(p);
const int n = (int)p.cells.size();
const double sea = p.cfg.seaLevel;
// --- Density fields ------------------------------------------------------
const auto& fl = p.floraDensity(); const auto& fa = p.faunaDensity(); const auto& fu = p.fungaDensity();
check((int)fl.size() == n && (int)fa.size() == n && (int)fu.size() == n, "density fields sized n");
bool ranged = true, 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 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(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)");
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, iceEmpty = true, oceanFungaEmpty = true;
bool anyMarineOrg = false;
auto cost = [&](const std::vector<Organism>& v) { int s = 0; const auto& AR = biotaArchetypes();
for (auto& o : v) s += pointCost(AR[o.archetype].size); return s; };
for (int i = 0; i < n; ++i) {
const CellBiota& cb = B[i];
if (p.cells[i].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;
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(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");
// --- Determinism: same seed -> identical population ----------------------
std::vector<CellBiota> first = p.biota();
p.generateBiota();
check(sameBiota(first, p.biota()), "generateBiota() is deterministic (re-run identical)");
// --- RNG isolation: generating biota must not perturb tectonics ----------
{
Planet a; a.generate(cfg); settle(a); a.drifting = true;
Planet b; b.generate(cfg); settle(b); b.drifting = true;
b.generateBiota(); // only b generates biota
double dt = a.cflDtMy();
for (int k = 0; k < 5; ++k) { a.advect(dt); a.step(); a.erode(dt);
b.advect(dt); b.step(); b.erode(dt); }
bool identical = a.cells.size() == b.cells.size();
for (size_t i = 0; identical && i < a.cells.size(); ++i)
if (a.cells[i].elevation != b.cells[i].elevation || a.cells[i].plateId != b.cells[i].plateId)
identical = false;
check(identical, "biota generation does not change tectonic evolution (separate RNG)");
}
// --- Save v7 round-trip + v6-style read (empty population) ---------------
{
std::ostringstream os(std::ios::binary);
p.writeState(os);
std::string blob = os.str();
Planet q; std::istringstream is(blob, std::ios::binary);
bool ok = q.readState(is, /*hasBiome*/true, /*hasBiota*/true);
check(ok && q.biotaPopulated() && sameBiota(p.biota(), q.biota()), "save v7 round-trips the biota population");
Planet r; std::istringstream is2(blob, std::ios::binary);
bool ok2 = r.readState(is2, /*hasBiome*/true, /*hasBiota*/false); // old (pre-v7) read path
check(ok2 && !r.biotaPopulated(), "pre-v7 read leaves population empty (loads fine)");
}
std::printf("\n%s (%d failure%s)\n", failures ? "FAILURES" : "ALL PASS",
failures, failures == 1 ? "" : "s");
return failures ? 1 : 0;
}