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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:56:03 +02:00

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