planetsim/test_geography.cpp
Jonas Reith 8ae2f14bae Geography polish: ocean basins, unique name roots, name new islands
Feedback fixes on the just-shipped atlas:

- Ocean basins: the connected world ocean was always one feature ("1 ocean",
  unrealistic). Now generateGeography partitions it via a distance-from-land
  watershed -- greedy farthest-first deep-water centres (geoOceanSepRadians apart,
  >= geoOceanDeep rings from land) + multi-source BFS Voronoi over the ocean
  graph. Each basin -> a named Ocean (or Sea if small). Defaults (sep 1.4 rad,
  deep 4) give ~4-6 oceans on an Earth-like world, 1 on a waterworld. New geo*
  knobs geoOceanSepRadians / geoOceanDeep.
- Shared names: features now dedupe on the PROPER-NOUN root (not the formatted
  string), so a continent "Karn", a "Karn River" and "Karn Mountains" can't
  coexist. NameGen also avoids identical adjacent syllables ("shio-shio").
- New islands: Planet::nameNewLand(cell) adds a volcanic island to the atlas on
  the fly when it breaches the sea -- joins an adjacent landmass or mints a fresh
  unique Island name; the island-formation WorldEvent now carries that name.

test_geography adds: proper-noun-root uniqueness, nameNewLand join vs mint.
All 8 headless suites pass; GUI build clean. Docs updated. (Save still v17 --
GeoFeature layout unchanged.)

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

209 lines
11 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");
// Proper-noun uniqueness: strip the kind suffix/prefix and assert no two features share a root
// (so a continent "Karn", a "Karn River" and "Karn Mountains" can't coexist).
{
auto strip = [](const GeoFeature& f) -> std::string {
const std::string& s = f.name;
auto cut = [&](const std::string& suf) { return s.size() > suf.size() && s.compare(s.size() - suf.size(), suf.size(), suf) == 0 ? s.substr(0, s.size() - suf.size()) : s; };
switch (f.kind) {
case FeatureKind::Ocean: return cut(" Ocean");
case FeatureKind::Sea: return cut(" Sea");
case FeatureKind::MountainRange: return cut(" Mountains");
case FeatureKind::River: return cut(" River");
case FeatureKind::Lake: return s.rfind("Lake ", 0) == 0 ? s.substr(5) : s;
case FeatureKind::Peak: return s.rfind("Mount ", 0) == 0 ? s.substr(6) : s;
default: return s;
}
};
std::set<std::string> roots; bool rootsUnique = true;
for (const GeoFeature& f : F) if (!roots.insert(strip(f)).second) rootsUnique = false;
check(rootsUnique, "no two features share a proper-noun root");
}
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");
}
// Naming new land last -- it mutates p's atlas, so it must run after the determinism/save checks.
std::printf("Geography: naming a new (volcanic) island\n");
{
int oceanAdjLand = -1, oceanIsolated = -1;
for (int i = 0; i < n && (oceanAdjLand < 0 || oceanIsolated < 0); ++i) {
if (p.cells[i].elevation > sea) continue;
bool nearLand = false; for (int j : p.cells[i].neighbors) if (p.cells[j].elevation > sea) nearLand = true;
if (nearLand && oceanAdjLand < 0) oceanAdjLand = i;
if (!nearLand && oceanIsolated < 0) oceanIsolated = i;
}
if (oceanAdjLand >= 0) {
size_t before = p.geography().size();
std::string nm = p.nameNewLand(oceanAdjLand);
check(!nm.empty() && p.cellLand()[oceanAdjLand] >= 0, "coastal new land joins an adjacent named landmass");
check(p.geography().size() == before, "joining an existing landmass adds no new feature");
}
if (oceanIsolated >= 0) {
size_t b2 = p.geography().size();
std::string nm = p.nameNewLand(oceanIsolated);
check(!nm.empty() && p.geography().size() == b2 + 1, "an isolated new island mints a fresh feature");
check(p.geography().back().kind == FeatureKind::Island && p.cellLand()[oceanIsolated] >= 0, "the new island is a named Island feature");
}
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL GEOGRAPHY CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}