258 lines
12 KiB
C++
258 lines
12 KiB
C++
// Headless logic test for Phase 1 tectonics. No display / raylib needed.
|
|
//
|
|
// 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/PlanetIO.cpp
|
|
// -o /tmp/t && /tmp/t
|
|
//
|
|
// Verifies the invariants documented in CLAUDE.md so Planet::step() and the
|
|
// icosphere can be changed with confidence without launching the window.
|
|
|
|
#include "Planet.hpp"
|
|
#include "Projection.hpp"
|
|
#include <cstdio>
|
|
#include <map>
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
#include <sstream>
|
|
#include <cstdint>
|
|
|
|
static int failures = 0;
|
|
|
|
static double angBetweenDeg(const Vec3& a, const Vec3& b) {
|
|
return std::acos(std::clamp(a.dot(b), -1.0, 1.0)) * 180.0 / M_PI;
|
|
}
|
|
|
|
static void check(bool cond, const char* what) {
|
|
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
|
|
if (!cond) ++failures;
|
|
}
|
|
|
|
template <class T> static void writePod(std::ostream& os, const T& v) {
|
|
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
|
|
}
|
|
template <class T> static void writeEmptyVec(std::ostream& os) {
|
|
uint64_t n = 0; writePod(os, n);
|
|
}
|
|
|
|
static void writeManualStatePrefix(std::ostream& os, const std::string& cfgText, int plateId) {
|
|
uint64_t clen = cfgText.size(); writePod(os, clen);
|
|
os.write(cfgText.data(), (std::streamsize)cfgText.size());
|
|
uint32_t rng = 1; int driftIter = 0, erodeIter = 0, targetLand = -1;
|
|
writePod(os, rng); writePod(os, driftIter); writePod(os, erodeIter); writePod(os, targetLand);
|
|
uint64_t nc = 12; writePod(os, nc); // subdivisions=0
|
|
for (int i = 0; i < 12; ++i) {
|
|
double elevation = 0.0, geoAge = 0.0, drift = 0.0;
|
|
uint8_t oceanic = 1, biome = (uint8_t)Biome::Ocean;
|
|
int invader = -1;
|
|
writePod(os, elevation); writePod(os, plateId); writePod(os, oceanic);
|
|
writePod(os, geoAge); writePod(os, drift); writePod(os, invader); writePod(os, biome);
|
|
}
|
|
}
|
|
|
|
static void finishManualState(std::ostream& os) {
|
|
uint64_t pn = 1; writePod(os, pn);
|
|
Plate pl{}; pl.id = 0; pl.driftAxis = Vec3{0, 1, 0};
|
|
os.write(reinterpret_cast<const char*>(&pl), sizeof(Plate));
|
|
writeEmptyVec<int>(os); // sPrevCount
|
|
writeEmptyVec<int>(os); // sStaleStreak
|
|
writeEmptyVec<int>(os); // sFreePlateIds
|
|
writeEmptyVec<Moon>(os); // moons
|
|
uint8_t hasBio = 0, hasWx = 0;
|
|
writePod(os, hasBio);
|
|
writePod(os, hasWx);
|
|
}
|
|
|
|
// Euler characteristic for the icosphere: V - E + F == 2 (sphere topology).
|
|
static bool eulerOk(const Planet& p, size_t vertCount) {
|
|
const std::vector<int>& tri = p.triIndices();
|
|
size_t F = tri.size() / 3;
|
|
// Each interior edge is shared by exactly 2 triangles; count unique edges.
|
|
std::map<std::pair<int,int>, int> edges;
|
|
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
|
|
int a = tri[k], b = tri[k + 1], c = tri[k + 2];
|
|
int e[3][2] = {{a, b}, {b, c}, {c, a}};
|
|
for (auto& pr : e) {
|
|
int lo = std::min(pr[0], pr[1]), hi = std::max(pr[0], pr[1]);
|
|
edges[{lo, hi}]++;
|
|
}
|
|
}
|
|
size_t E = edges.size();
|
|
long long euler = (long long)vertCount - (long long)E + (long long)F;
|
|
std::printf(" V=%zu E=%zu F=%zu V-E+F=%lld\n", vertCount, E, F, euler);
|
|
return euler == 2;
|
|
}
|
|
|
|
int main() {
|
|
// ---- Geometry: icosphere level 5 -------------------------------------
|
|
Planet planet;
|
|
PlanetConfig cfg;
|
|
cfg.subdivisions = 5;
|
|
cfg.seed = 1337;
|
|
planet.generate(cfg);
|
|
|
|
std::printf("Geometry (level 5):\n");
|
|
check(planet.cells.size() == 10242, "10242 cells at subdivision 5");
|
|
check(eulerOk(planet, planet.cells.size()), "Euler characteristic V-E+F == 2");
|
|
|
|
// Vertex degrees: exactly 12 should have degree 5, the rest degree 6.
|
|
int deg5 = 0, deg6 = 0, other = 0;
|
|
for (auto& c : planet.cells) {
|
|
if (c.neighbors.size() == 5) ++deg5;
|
|
else if (c.neighbors.size() == 6) ++deg6;
|
|
else ++other;
|
|
}
|
|
std::printf(" degree5=%d degree6=%d other=%d\n", deg5, deg6, other);
|
|
check(deg5 == 12, "exactly 12 degree-5 vertices");
|
|
check(other == 0, "all remaining vertices are degree 6");
|
|
|
|
double cw = planet.cellWidthMeters() / 1000.0;
|
|
std::printf(" cell width ~ %.0f km\n", cw);
|
|
check(cw > 200.0 && cw < 250.0, "cell width ~223 km");
|
|
|
|
// ---- Plates ----------------------------------------------------------
|
|
bool allAssigned = true;
|
|
for (auto& c : planet.cells)
|
|
if (c.plateId < 0 || c.plateId >= cfg.plateCount) allAssigned = false;
|
|
check(allAssigned, "every cell assigned to a valid plate");
|
|
check((int)planet.plates.size() == cfg.plateCount, "plateCount plates created");
|
|
|
|
// ---- Tectonics: run ~40 ticks ----------------------------------------
|
|
for (int i = 0; i < 40; ++i) planet.step();
|
|
|
|
double lo = planet.minElevation(), hi = planet.maxElevation();
|
|
std::printf("Tectonics after 40 ticks:\n");
|
|
std::printf(" elevation range %.0f .. %.0f m\n", lo, hi);
|
|
check(hi > 2000.0, "clear mountains form (max > 2000 m)");
|
|
check(lo < -6000.0, "deep trenches form (min < -6000 m)");
|
|
|
|
// Relief must be GRADED, not saturated to the clamp rails (the old bug:
|
|
// uplift so strong every boundary cell railed to +/- the clamp in 1 tick).
|
|
int pinned = 0, midLand = 0, midSea = 0;
|
|
for (auto& c : planet.cells) {
|
|
if (c.elevation >= 8999.0 || c.elevation <= -10999.0) ++pinned;
|
|
if (c.elevation > 800.0 && c.elevation < 2000.0) ++midLand; // belt flanks
|
|
if (c.elevation < -4500.0 && c.elevation > -6000.0) ++midSea; // trench flanks
|
|
}
|
|
double pinnedPct = 100.0 * pinned / planet.cells.size();
|
|
std::printf(" pinned to clamp: %d (%.2f%%) flank cells: land=%d sea=%d\n",
|
|
pinned, pinnedPct, midLand, midSea);
|
|
check(pinnedPct < 2.0, "not saturated: <2% of cells pinned to clamp rails");
|
|
check(midLand > 0 && midSea > 0, "graded relief: mountains/trenches have flanks");
|
|
|
|
bool finite = true;
|
|
for (auto& c : planet.cells)
|
|
if (!std::isfinite(c.elevation)) finite = false;
|
|
check(finite, "no NaN/Inf elevations (numerically stable)");
|
|
|
|
// ---- Determinism: same seed -> identical result ----------------------
|
|
Planet p2;
|
|
p2.generate(cfg);
|
|
for (int i = 0; i < 40; ++i) p2.step();
|
|
bool identical = (p2.cells.size() == planet.cells.size());
|
|
for (size_t i = 0; identical && i < p2.cells.size(); ++i)
|
|
if (p2.cells[i].elevation != planet.cells[i].elevation) identical = false;
|
|
check(identical, "same seed reproduces identical world (deterministic)");
|
|
|
|
// ---- Equal Earth projection round-trip (used by the 2D map + hover) ---
|
|
{
|
|
double maxErr = 0.0; int rejected = 0;
|
|
for (const auto& c : planet.cells) {
|
|
double lon, lat; dirToLonLat(c.unit, lon, lat);
|
|
double x, y; EqualEarth::forward(lon, lat, x, y);
|
|
double lo2, la2;
|
|
if (!EqualEarth::inverse(x, y, lo2, la2)) { ++rejected; continue; }
|
|
Vec3 d2 = lonLatToDir(lo2, la2);
|
|
maxErr = std::max(maxErr, angBetweenDeg(c.unit, d2));
|
|
}
|
|
std::printf(" Equal Earth round-trip: max err %.2e deg, rejected %d\n", maxErr, rejected);
|
|
check(maxErr < 1e-3 && rejected == 0, "Equal Earth forward/inverse round-trips");
|
|
}
|
|
|
|
// ---- Subgrid: continuity at center + neighbor coverage ----------------
|
|
{
|
|
int cell = (int)planet.cells.size() / 2;
|
|
auto sg = planet.makeSubGrid(cell, 16);
|
|
bool sgFinite = true; int ownCell = 0, ownNbr = 0;
|
|
for (auto& s : sg->sub) {
|
|
if (!std::isfinite(s.elevation)) sgFinite = false;
|
|
if (s.nearestMacro == cell) ++ownCell; else ++ownNbr;
|
|
}
|
|
const SubCell& ctr = sg->sub[(16 / 2) * 16 + 16 / 2];
|
|
double centerErr = std::fabs(ctr.elevation - planet.cells[cell].elevation);
|
|
std::printf(" subgrid: %zu subcells, center |diff| %.0f m, own %d / nbr %d\n",
|
|
sg->sub.size(), centerErr, ownCell, ownNbr);
|
|
check(sgFinite && sg->sub.size() == 256 && centerErr < 800.0 && ownCell > 0 && ownNbr > 0,
|
|
"subgrid is finite, continuous at center, and reaches neighbors");
|
|
}
|
|
|
|
// ---- Derived state reset + save hardening ----------------------------
|
|
{
|
|
Planet stale; stale.generate(cfg);
|
|
stale.computeHydrology();
|
|
stale.computeInsolation(0.25, 0.5);
|
|
stale.computeLiveSeason(0.25);
|
|
stale.computeTides(0.25, 0.5, 10.0);
|
|
check(!stale.lakeDepth().empty() && !stale.liveTemp().empty() && !stale.tide().empty(),
|
|
"derived hydrology/live fields can be populated");
|
|
stale.generate(cfg);
|
|
check(stale.lakeDepth().empty() && stale.discharge().empty() && stale.flowTo().empty()
|
|
&& stale.insolation().empty() && stale.liveTemp().empty() && stale.tide().empty(),
|
|
"generate() clears stale hydrology/live derived fields");
|
|
|
|
PlanetConfig smaller = cfg; smaller.subdivisions = 4;
|
|
stale.computeHydrology();
|
|
stale.generate(smaller);
|
|
check(stale.lakeDepth().empty() && stale.cells.size() != planet.cells.size(),
|
|
"generate() clears derived fields across subdivision changes");
|
|
|
|
Planet saved; saved.generate(cfg); saved.computeHydrology();
|
|
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
|
|
saved.writeState(ss);
|
|
Planet loaded;
|
|
bool ok = loaded.readState(ss);
|
|
check(ok && loaded.lakeDepth().empty() && loaded.discharge().empty() && loaded.flowTo().empty(),
|
|
"readState() does not keep unsaved hydrology scratch");
|
|
}
|
|
|
|
{
|
|
std::stringstream badCfg(std::ios::in | std::ios::out | std::ios::binary);
|
|
std::string cfgText = "subdivisions = 99\nplateCount = 1\n";
|
|
uint64_t clen = cfgText.size(); writePod(badCfg, clen);
|
|
badCfg.write(cfgText.data(), (std::streamsize)cfgText.size());
|
|
Planet r;
|
|
check(!r.readState(badCfg), "readState() rejects invalid embedded config before geometry rebuild");
|
|
|
|
std::stringstream hugeVec(std::ios::in | std::ios::out | std::ios::binary);
|
|
writeManualStatePrefix(hugeVec, "subdivisions = 0\nplateCount = 1\n", 0);
|
|
uint64_t tooManyPlates = 13; writePod(hugeVec, tooManyPlates); // cells.size() is 12
|
|
Planet rv;
|
|
check(!rv.readState(hugeVec), "readState() rejects oversized vectors");
|
|
|
|
std::stringstream badPlate(std::ios::in | std::ios::out | std::ios::binary);
|
|
writeManualStatePrefix(badPlate, "subdivisions = 0\nplateCount = 1\n", 77);
|
|
finishManualState(badPlate);
|
|
Planet rp;
|
|
check(!rp.readState(badPlate), "readState() rejects invalid cell plate ids");
|
|
|
|
Planet victim; victim.generate(cfg);
|
|
const size_t beforeCells = victim.cells.size();
|
|
const int beforeSubdiv = victim.cfg.subdivisions;
|
|
const double beforeElev = victim.cells.empty() ? 0.0 : victim.cells[0].elevation;
|
|
std::stringstream badAfterBuild(std::ios::in | std::ios::out | std::ios::binary);
|
|
writeManualStatePrefix(badAfterBuild, "subdivisions = 0\nplateCount = 1\n", 77);
|
|
finishManualState(badAfterBuild);
|
|
bool rejected = !victim.readState(badAfterBuild);
|
|
bool unchanged = victim.cells.size() == beforeCells
|
|
&& victim.cfg.subdivisions == beforeSubdiv
|
|
&& !victim.cells.empty()
|
|
&& victim.cells[0].elevation == beforeElev;
|
|
check(rejected && unchanged, "failed readState() leaves the existing planet unchanged");
|
|
}
|
|
|
|
std::printf("\n%s (%d failure%s)\n",
|
|
failures == 0 ? "ALL TESTS PASSED" : "TESTS FAILED",
|
|
failures, failures == 1 ? "" : "s");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|