planetsim/test_volcano.cpp
Jonas Reith 275511713c Add volcanoes & volcanic islands (Live World, save v14)
On entering Live World a one-time pass places volcanoes by tectonic context
(very high prob on young spreading-ridge/"new-plate" cells, medium on plate
borders, low elsewhere). Over the live clock they erupt; submarine vents build
up and breach sea level into new volcanic islands, land vents grow cones, and
each eruption injects a drifting ash cloud + local cooling into the weather.

Design: eruption state (built height + intensity) is a PURE FUNCTION of liveTime
(like insolation/tides/seasons), so the live stepper rewinds islands & eruptions
for free -- no per-cell snapshot, no volcano undo history. The only integrated
side-effect is the ash plume into sCloud (reverts via the weather snapshot).

- src/sim/PlanetVolcano.cpp (new): placeVolcanoes (separate RNG, reservoir-
  sampled to volcanoMaxCount; tectonic determinism intact) + stepVolcanoes
  (reassert elevation = baseElev + built(liveTime); breach/un-breach; ash).
- Volcano struct + volcano* config knobs (PlanetTypes.hpp); Planet members +
  decls; readState gains hasVolcanoes; CONFIG_FIELDS + validateConfig.
- Save bumped to v14: flag-gated volcano block (set + sVolRng) in writeState/
  readState; pre-v14 saves load with none and place on next Live World entry.
- Render: 3D cone + eruption glow/ash-plume (DrawCylinderEx) and 2D triangle
  markers, key V toggle, HUD line, cell-info volcano line. Lazy placement on W
  entry and on loading a live-world save with no volcanoes.
- test_volcano.cpp (new, registered in CMake): determinism, RNG isolation,
  context classification + probability ordering, monotonic build + sea-level
  breach + step-back recede (pure function of liveTime), ash->cloud, v14
  round-trip. All six headless suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:48:45 +02:00

164 lines
8.6 KiB
C++

// Headless test for Live World volcanoes (placement by tectonic context + eruption / island
// building). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_volcano.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/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/tv && /tmp/tv
//
// Verifies: placement is deterministic + isolated from the tectonic RNG; the context classification
// (ridge / border / interior) drives where vents land and respects the probabilities; a submarine
// vent's built height is a monotonic PURE FUNCTION of liveTime that breaches sea level into an island
// and recedes when the clock steps back; and an eruption injects ash cloud at the vent.
#include "Planet.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <vector>
#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();
}
static void drift(Planet& p, int iters) {
p.drifting = true;
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); }
}
// Replicate placeVolcanoes()'s context classification: 0 = ridge (baby plate / neighbour baby),
// 1 = plate border (a differing-plate neighbour), 2 = interior.
static int classify(const Planet& p, int i) {
auto isBaby = [&](int pid) { return pid >= 0 && pid < (int)p.plates.size() && p.plates[pid].baby; };
int pid = p.cells[i].plateId;
bool ridge = isBaby(pid), border = false;
for (int j : p.cells[i].neighbors) { int pj = p.cells[j].plateId; if (pj != pid) border = true; if (isBaby(pj)) ridge = true; }
return ridge ? 0 : (border ? 1 : 2);
}
static bool sameVolcanoes(const std::vector<Volcano>& a, const std::vector<Volcano>& b) {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i)
if (a[i].id != b[i].id || a[i].cell != b[i].cell || a[i].kind != b[i].kind
|| a[i].submarine != b[i].submarine || a[i].activity != b[i].activity
|| a[i].baseElev != b[i].baseElev || a[i].tStart != b[i].tStart) return false;
return true;
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 9090;
Planet p; p.generate(cfg); settle(p); drift(p, 120);
const int n = (int)p.cells.size();
std::printf("Volcanoes: determinism\n");
p.placeVolcanoes(0.0); std::vector<Volcano> first = p.volcanoes;
p.placeVolcanoes(0.0);
check(!first.empty(), "placeVolcanoes places a non-empty set");
check(sameVolcanoes(first, p.volcanoes), "placeVolcanoes is deterministic (re-run identical)");
std::printf("Volcanoes: RNG isolation from tectonics\n");
Planet a; a.generate(cfg); settle(a);
Planet b; b.generate(cfg); settle(b);
for (int k = 0; k < 40; ++k) {
double dta = a.cflDtMy(); a.advect(dta); a.step(); a.erode(dta);
double dtb = b.cflDtMy(); b.advect(dtb); b.step(); b.erode(dtb);
if (k == 20) b.placeVolcanoes(0.0); // must not touch the tectonic RNG stream
}
bool terrainSame = true;
for (int i = 0; i < n; ++i) if (std::fabs(a.cells[i].elevation - b.cells[i].elevation) > 1e-9) terrainSame = false;
check(terrainSame, "placeVolcanoes never perturbs tectonic evolution");
std::printf("Volcanoes: context classification\n");
int eligRidge = 0, eligBorder = 0, eligInterior = 0;
for (int i = 0; i < n; ++i) { int k = classify(p, i); if (k == 0) ++eligRidge; else if (k == 1) ++eligBorder; else ++eligInterior; }
std::printf(" eligible cells: ridge %d, border %d, interior %d\n", eligRidge, eligBorder, eligInterior);
// probs ridge=border=1, interior=0, no cap -> exactly the ridge+border cells, none interior.
p.cfg.volcanoProbRidge = 1.0; p.cfg.volcanoProbBorder = 1.0; p.cfg.volcanoProbInterior = 0.0;
p.cfg.volcanoMaxCount = 1000000;
p.placeVolcanoes(0.0);
bool noInterior = true; for (const Volcano& v : p.volcanoes) if (v.kind == 2) noInterior = false;
check(noInterior, "interior prob 0 places no interior vents");
check((int)p.volcanoes.size() == eligRidge + eligBorder, "prob 1 fills exactly the ridge+border cells");
std::printf("Volcanoes: probability ordering (border > interior)\n");
p.cfg.volcanoProbRidge = 1.0; p.cfg.volcanoProbBorder = 0.30; p.cfg.volcanoProbInterior = 0.05;
p.placeVolcanoes(0.0);
int gotRidge = 0, gotBorder = 0, gotInterior = 0;
for (const Volcano& v : p.volcanoes) { if (v.kind == 0) ++gotRidge; else if (v.kind == 1) ++gotBorder; else ++gotInterior; }
double rB = eligBorder ? (double)gotBorder / eligBorder : 0.0;
double rI = eligInterior ? (double)gotInterior / eligInterior : 0.0;
std::printf(" placement rate: border %.3f, interior %.3f\n", rB, rI);
check(rB > rI, "border cells are far likelier to host a volcano than interior cells");
if (eligRidge > 0) {
double rR = (double)gotRidge / eligRidge;
std::printf(" placement rate: ridge %.3f\n", rR);
check(rR >= rB, "young-ridge cells are the likeliest of all");
} else std::printf(" (no young-ridge cells this seed -- ridge rate not asserted)\n");
std::printf("Volcanoes: build is a pure function of liveTime; submarine vent breaches into an island\n");
Planet q; q.generate(cfg); settle(q); drift(q, 120);
q.cfg.volcanoMaxHeight = 9000.0; q.cfg.volcanoBuildStep = 130.0; q.cfg.volcanoEruptFreq = 0.05;
q.placeVolcanoes(0.0);
int vi = -1; double best = -1e18;
for (size_t k = 0; k < q.volcanoes.size(); ++k)
if (q.volcanoes[k].submarine && q.volcanoes[k].baseElev > best) { best = q.volcanoes[k].baseElev; vi = (int)k; }
check(vi >= 0, "at least one submarine volcano was placed");
if (vi >= 0) {
const Volcano v = q.volcanoes[vi];
double b0 = q.volcanoBuilt(v, 0.0), b1 = q.volcanoBuilt(v, 5000.0),
b2 = q.volcanoBuilt(v, 50000.0), b3 = q.volcanoBuilt(v, 500000.0);
check(b0 <= b1 && b1 <= b2 && b2 <= b3, "built height is monotonic in liveTime");
check(b3 > b0, "a submarine vent builds up over time");
check(q.volcanoBuilt(v, 5000.0) == b1, "volcanoBuilt is deterministic (pure function of t)");
q.stepVolcanoes(1.0, 500000.0);
check(q.cells[v.cell].elevation > q.cfg.seaLevel, "submarine volcano breaches sea level into an island");
check(!q.cells[v.cell].oceanic, "the breached island is land crust");
// Step the clock back to the start: the island must recede (pure function of liveTime).
q.stepVolcanoes(0.0, 0.0);
check(q.cells[v.cell].elevation <= q.cfg.seaLevel + 1e-6, "stepping the clock back recedes the island");
check(std::fabs(q.cells[v.cell].elevation - (v.baseElev + q.volcanoBuilt(v, 0.0))) < 1e-6,
"vent elevation = baseElev + built(liveTime)");
}
std::printf("Volcanoes: an eruption injects ash cloud\n");
Planet w; w.generate(cfg); settle(w);
w.initWeather();
w.computeInsolation(0.25, 0.3);
w.placeVolcanoes(0.0); // tStart = 0 -> at liveTime 0 every vent is at peak eruption intensity
check(!w.volcanoes.empty(), "volcanoes placed for the ash test");
if (!w.volcanoes.empty()) {
std::vector<double> before = w.cloud();
w.stepVolcanoes(1.0, 0.0); // dtHours > 0 -> inject ash
bool rose = false;
for (const Volcano& vv : w.volcanoes)
if (w.cloud()[vv.cell] > before[vv.cell] + 1e-9) rose = true;
check(rose, "an erupting vent thickens the cloud at its cell");
}
std::printf("Volcanoes: save v14 round-trip\n");
{
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
q.writeState(ss);
Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true);
check(ok, "readState accepts a v14 stream");
check(sameVolcanoes(q.volcanoes, r.volcanoes), "volcano set round-trips through save");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL VOLCANO CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}