planetsim/test_geography.cpp
Jonas Reith a4a06996fa Civilizations Step 1: geography & place-naming (the atlas, save v17)
The foundation of the civilization arc: name the world so everything civic
(territory, borders, place-of-origin) can reference it. This is pure derived
geometry + a deterministic namer, so it lives in the raylib-free engine and is
fully testable headless. No agents/clock yet -- those come in later steps.

- NameGen.{hpp,cpp} (new): deterministic procedural name generator (xorshift
  syllable banks; bankForRegion gives each continent a "language" so its rivers/
  mountains share a sound). Reused by the whole civ arc later.
- PlanetGeography.{hpp,cpp} (new): Planet::generateGeography() extracts named
  features by connectivity over the fixed grid -- continents/islands (connected
  land), oceans/seas (connected water), lakes (inland filled basins), mountain
  ranges + peaks (connected high terrain), rivers (largest discharge mouths
  traced upstream via flowTo). Separate RNG (sGeoRng) keeps tectonic determinism
  intact; per-cell index arrays (sCellLand/Water/Range/River) give O(1) lookup.
- Save v17: geography block (feature records with names + per-cell region arrays)
  appended in writeState/readState; readState gains hasGeography; pre-v17 saves
  load with none (regenerated on M). geo* config knobs + validation.
- Render: key M toggles place-name labels on globe (manual projection) + 2D map
  (minor features only when zoomed); a 5th "Atlas" live-info tab lists features
  by kind (click a row -> focusCell); cell-info shows a "region" line. Generated
  lazily on a settled world (M) or on entering Live World (W).
- test_geography.cpp (new, in CMake foreach): extraction, per-cell membership,
  river-traces-to-sink, names unique/deterministic, RNG isolation, v17 round-trip.
  All 8 headless suites pass; GUI build clean. Docs updated (CLAUDE/design-notes/
  BUILD), incl. the multi-step civilization roadmap.

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

164 lines
8.1 KiB
C++

// Headless test for the geography / atlas stage (named feature extraction + naming). No display.
//
// g++ -std=c++17 -O2 -Isrc/sim test_geography.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/NameGen.cpp \
// src/sim/PlanetGeography.cpp src/sim/PlanetIO.cpp -o /tmp/tg && /tmp/tg
//
// Verifies: extraction (continents/oceans/ranges/rivers/lakes), per-cell membership consistency,
// names non-empty/unique/deterministic, RNG isolation from tectonics, and a v17 save round-trip.
#include "Planet.hpp"
#include "NameGen.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <set>
#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); }
}
static int countKind(const Planet& p, FeatureKind k) {
int c = 0; for (const GeoFeature& f : p.geography()) if (f.kind == k) ++c; return c;
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 7777;
Planet p; p.generate(cfg); settle(p); drift(p, 120);
const int n = (int)p.cells.size();
const double sea = p.cfg.seaLevel;
std::printf("Geography: NameGen\n");
std::string a1 = namegen::makeName(123, 0), a2 = namegen::makeName(123, 0);
check(!a1.empty() && a1 == a2, "makeName deterministic + non-empty");
check(namegen::makeName(124, 0) != a1, "different seeds give different names (usually)");
check(namegen::bankForRegion(cfg.seed, 5) == namegen::bankForRegion(cfg.seed, 5), "bankForRegion deterministic");
std::printf("Geography: extraction\n");
p.generateGeography();
const auto& F = p.geography();
check(!F.empty(), "generateGeography produces features");
int continents = countKind(p, FeatureKind::Continent), islands = countKind(p, FeatureKind::Island);
int oceans = countKind(p, FeatureKind::Ocean), seas = countKind(p, FeatureKind::Sea);
int ranges = countKind(p, FeatureKind::MountainRange), peaks = countKind(p, FeatureKind::Peak);
int rivers = countKind(p, FeatureKind::River), lakes = countKind(p, FeatureKind::Lake);
std::printf(" continents %d islands %d | oceans %d seas %d | ranges %d peaks %d | rivers %d lakes %d\n",
continents, islands, oceans, seas, ranges, peaks, rivers, lakes);
check(continents + islands >= 1, "at least one land mass");
check(oceans >= 1, "at least one ocean");
std::printf("Geography: per-cell membership consistency\n");
const auto& land = p.cellLand(); const auto& water = p.cellWater();
const auto& range = p.cellRange();
check((int)land.size() == n && (int)water.size() == n, "index arrays sized n");
bool landOk = true, waterOk = true, rangeOk = true, idxOk = true;
for (int i = 0; i < n; ++i) {
bool isLand = p.cells[i].elevation > sea;
if (isLand && land[i] < 0) landOk = false; // every land cell has a continent/island
if (!isLand && land[i] >= 0) landOk = false; // ocean cells aren't a land feature
if (!isLand && water[i] < 0) waterOk = false; // every ocean cell has a water feature
if (range[i] >= 0 && !(p.cells[i].elevation > p.cfg.geoMountainElev)) rangeOk = false; // range cells are high
for (int v : {land[i], water[i], range[i]})
if (v >= (int)F.size()) idxOk = false;
}
check(landOk, "land cells map to a continent/island; ocean cells don't");
check(waterOk, "ocean cells map to an ocean/sea feature");
check(rangeOk, "mountain-range cells are all above geoMountainElev");
check(idxOk, "per-cell feature indices are in range");
std::printf("Geography: features anchor on the right terrain\n");
bool anchorsOk = true;
for (const GeoFeature& f : F) {
const Cell& c = p.cells[f.anchorCell];
bool landKind = (f.kind == FeatureKind::Continent || f.kind == FeatureKind::Island ||
f.kind == FeatureKind::MountainRange || f.kind == FeatureKind::Peak ||
f.kind == FeatureKind::River || f.kind == FeatureKind::Lake);
bool oceanKind = (f.kind == FeatureKind::Ocean || f.kind == FeatureKind::Sea);
if (landKind && c.elevation <= sea) anchorsOk = false;
if (oceanKind && c.elevation > sea) anchorsOk = false;
}
check(anchorsOk, "land features anchor on land, ocean features on water");
if (rivers > 0) {
std::printf("Geography: a named river traces downstream to a sink/ocean\n");
const auto& fl = p.flowTo(); int riverCell = -1;
for (int i = 0; i < n; ++i) if (p.cellRiver()[i] >= 0) { riverCell = i; break; }
bool reaches = false;
for (int cur = riverCell, guard = 0; cur >= 0 && guard < n; ++guard) {
int d = fl[cur];
if (d < 0 || p.cells[d].elevation <= sea) { reaches = true; break; }
cur = d;
}
check(riverCell >= 0 && reaches, "a river cell flows down to an ocean/sink");
}
std::printf("Geography: names non-empty + unique\n");
bool namesOk = true; std::set<std::string> seen;
for (const GeoFeature& f : F) {
if (f.name.empty()) namesOk = false;
if (!seen.insert(f.name).second) namesOk = false; // no duplicates
}
check(namesOk, "every feature has a unique non-empty name");
std::printf("Geography: determinism (same seed -> identical atlas)\n");
Planet q; q.generate(cfg); settle(q); drift(q, 120); q.generateGeography();
bool sameAtlas = (q.geography().size() == F.size());
if (sameAtlas)
for (size_t i = 0; i < F.size(); ++i)
if (q.geography()[i].kind != F[i].kind || q.geography()[i].anchorCell != F[i].anchorCell
|| q.geography()[i].name != F[i].name) { sameAtlas = false; break; }
check(sameAtlas, "generateGeography is deterministic");
std::printf("Geography: RNG isolation from tectonics\n");
Planet x; x.generate(cfg); settle(x);
Planet y; y.generate(cfg); settle(y);
for (int k = 0; k < 40; ++k) {
double dtx = x.cflDtMy(); x.advect(dtx); x.step(); x.erode(dtx);
double dty = y.cflDtMy(); y.advect(dty); y.step(); y.erode(dty);
if (k == 20) y.generateGeography(); // must not touch the tectonic RNG stream
}
bool terrainSame = true;
for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) terrainSame = false;
check(terrainSame, "generateGeography never perturbs tectonic evolution");
std::printf("Geography: save v17 round-trip\n");
{
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss);
Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true, true, true);
check(ok, "readState accepts a v17 stream");
bool match = (r.geography().size() == F.size());
if (match)
for (size_t i = 0; i < F.size(); ++i)
if (r.geography()[i].name != F[i].name || r.geography()[i].kind != F[i].kind
|| r.geography()[i].anchorCell != F[i].anchorCell) { match = false; break; }
check(match, "geography round-trips through save");
check(r.cellLand() == p.cellLand() && r.cellRiver() == p.cellRiver(), "per-cell region arrays round-trip");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL GEOGRAPHY CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}