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>
This commit is contained in:
Jonas Reith 2026-06-29 17:24:58 +02:00
parent a4a06996fa
commit 8ae2f14bae
10 changed files with 193 additions and 30 deletions

View File

@ -268,7 +268,9 @@ terrain (continents, islands, oceans, seas, lakes, mountain ranges, peaks, river
once on a settled world, saved v17. The foundation of the civilization arc.
geoContinentMinCells 40 land component >= this many cells = Continent (else Island)
geoSeaMaxCells 60 ocean component <= this many cells = Sea (else Ocean)
geoSeaMaxCells 60 ocean basin <= this many cells = Sea (else Ocean)
geoOceanSepRadians 1.4 min angular gap between ocean-basin centres (higher = fewer oceans)
geoOceanDeep 4 min rings from land for a cell to seed an ocean basin
geoMountainElev 2500 m min elevation for a mountain-range cell
geoRangeMinCells 4 min cells for a named mountain range
geoRiverMinDischarge 80 min mouth discharge for a named river

View File

@ -112,14 +112,17 @@ on the Live World clock). **Step 1 of the roadmap is done:**
- **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the
foundation everything civic references. `Planet::generateGeography()` extracts named features from
the (frozen) terrain 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`) — and
**oceans** (the connected world ocean split into basins by a distance-from-land watershed → ~46
named oceans) **/ seas** (small water bodies), **lakes** (inland filled basins), **mountain ranges +
peaks** (connected high terrain), **rivers** (largest discharge mouths traced upstream via `flowTo`) — and
names each with a deterministic procedural namer (`NameGen`: syllable banks, a "language" per
continent so a region's places share a sound). A separate RNG (`sGeoRng`) keeps tectonic determinism
intact. Generated once on a settled world (key `M`, in or out of Live World), drawn as labels on the
globe + 2D map (minor features only when zoomed), listed in an **Atlas** tab (click a row to fly
there), and shown in cell-info as a "region" line. Per-cell feature-index arrays give O(1) "which
features is this cell in" (the hook for territory/borders later). Saved (**v17**). Knobs `geo*`.
features is this cell in" (the hook for territory/borders later). Names dedupe on the proper-noun
root (no two features share a base name); a **new volcanic island** is named on the fly when it
breaches (the island-formation event carries its name). Saved (**v17**). Knobs `geo*`.
*Next steps (not yet built): settlements + food/habitability, territory + borders, culture +
beliefs, conflict + diplomacy.*
@ -812,11 +815,17 @@ triangles (plates are fixed in phase 1).
`volcanoAshCooling` (6 °C). Marker sizes/colours are render constants (ViewerRender.cpp).
- **Geography / atlas (`geo*` in PlanetConfig / `planet.cfg`):** feature-extraction thresholds —
`geoContinentMinCells` (40, land component ≥ this = Continent, else Island), `geoSeaMaxCells`
(60, ocean component ≤ this = Sea, else Ocean), `geoMountainElev` (2500 m, min elevation for a
(60, ocean **basin** ≤ this = Sea, else Ocean), `geoMountainElev` (2500 m, min elevation for a
mountain-range cell), `geoRangeMinCells` (4, min cells for a named range), `geoRiverMinDischarge`
(80, min mouth discharge for a named river), and the label-clutter caps `geoMaxRivers` (40) /
`geoMaxPeaks` (40, largest/highest kept). Name flavour (syllable banks, a "language" per continent)
+ label fonts/colours are constants in NameGen.cpp / ViewerRender.cpp, not config.
`geoMaxPeaks` (40, largest/highest kept). **Ocean basins** — the connected world ocean is split
into several named oceans by a distance-from-land watershed: `geoOceanSepRadians` (1.40 rad, min
angular gap between basin centres — *raise → fewer oceans*, lower → more) and `geoOceanDeep` (4, min
rings from land for a cell to seed a basin); defaults give ~46 oceans on an Earth-like world, 1 on a
waterworld. Feature names are deduped on the **proper-noun root** (no shared roots across kinds), and
a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints
a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
constants in NameGen.cpp / ViewerRender.cpp, not config.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -305,7 +305,10 @@ Save **v16** appends the event log; pre-v16 saves load with an empty journal.
civilization arc: name the world so everything civic can reference it. `Planet::generateGeography()`
extracts geographic features from the frozen terrain purely by **connectivity over the fixed grid**
(the same flood-fill idiom as `coalesceBabyPlates` / the enclosed-sea fill): connected land →
**Continent** (≥ `geoContinentMinCells`) or **Island**; connected water → **Ocean** or **Sea** (≤
**Continent** (≥ `geoContinentMinCells`) or **Island**; the connected world ocean is split into
**basins** (a single connected body reads wrong as one name) by a distance-from-land watershed —
greedy farthest-first deep-water **centres** (`geoOceanSepRadians` apart, ≥ `geoOceanDeep` rings from
land) then multi-source BFS Voronoi over the ocean graph — each basin → an **Ocean** (or **Sea** if ≤
`geoSeaMaxCells`); inland filled basins (`lakeDepth`) → **Lake**; connected `> geoMountainElev` land →
**MountainRange** + its highest cell as a **Peak**; the largest `discharge` mouths traced upstream via
`flowTo`**River**. It first calls `computeHydrology()` (routing only — no elevation change) so the
@ -323,6 +326,10 @@ names as labels on the globe (the plate-label manual projection) + 2D map (minor
zoomed, to declutter), lists them in an **Atlas** tab (5th live-info tab; click a row → `focusCell`),
and adds a "region" line to cell-info. Save v17 appends the feature records (with `std::string` names,
written field-by-field) + the POD index arrays; pre-v17 saves load with none and regenerate on demand.
Names dedupe on the **proper-noun root** (not the formatted string), so a continent, its river and its
mountains can't share a base name. New land created during Live World (a volcanic island breaching the
sea) is added to the atlas on the fly by `Planet::nameNewLand(cell)` — it joins an adjacent existing
landmass or mints a fresh unique Island name, which the island-formation `WorldEvent` then carries.
## Headless testing

View File

@ -316,7 +316,11 @@ void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
double oldElev = old->baseElev + old->built;
double newElev = v.baseElev + v.built;
if (old->submarine && oldElev <= planet.cfg.seaLevel && newElev > planet.cfg.seaLevel) {
appendEvent(2, 1, liveTime, v.cell, v.id, "Volcanic island formed",
// Add the new land to the atlas: join an adjacent landmass or mint a fresh Island name.
std::string land = planet.nameNewLand(v.cell);
std::string title = land.empty() ? std::string("Volcanic island formed")
: land + " formed";
appendEvent(2, 1, liveTime, v.cell, v.id, title,
std::string(TextFormat("%s breached sea level (+%.0f m built)", kind, v.built)));
}
if (old->phase != 1 && v.phase == 1) {

View File

@ -58,13 +58,17 @@ std::string makeName(uint32_t seed, int bank, int minSyl, int maxSyl) {
uint32_t s = seed ? seed : 0xA5A5A5A5u;
nx(s); nx(s); // mix the seed before first use
int syl = minSyl + (int)(nx(s) % (uint32_t)(maxSyl - minSyl + 1));
std::string out;
std::string out, prevSyl;
for (int i = 0; i < syl; ++i) {
out += pick(b.onset, s);
out += pick(b.nucleus, s);
// Coda more likely on the final syllable; never two empty-onset vowels colliding awkwardly.
bool last = (i == syl - 1);
if (last || (nx(s) & 3u) == 0u) out += pick(b.coda, s);
std::string cur;
for (int tries = 0; tries < 4; ++tries) {
cur = pick(b.onset, s);
cur += pick(b.nucleus, s);
if (last || (nx(s) & 3u) == 0u) cur += pick(b.coda, s); // coda likelier on the final syllable
if (cur != prevSyl) break; // avoid an identical adjacent syllable ("shio-shio")
}
out += cur; prevSyl = cur;
}
// Collapse runs of 3+ identical letters to 2 (kills "oooo" / "lll" -> "oo" / "ll").
std::string clean;

View File

@ -148,6 +148,11 @@ public:
// lakes, mountain ranges/peaks, rivers) and names them with a separate RNG (tectonic determinism
// intact). Saved (v17). The per-cell index arrays give O(1) "which features is this cell in".
void generateGeography();
// Name a cell that has just become land (e.g. a volcanic island breaching the sea): join an
// adjacent existing landmass, else create + name a new Island. Returns the land feature's name
// ("" if the atlas isn't built). Appended to geoFeatures (saved). Called by the viewer on a
// volcanic island-formation event so the new island enters the atlas.
std::string nameNewLand(int cell);
bool geographyBuilt() const { return !geoFeatures.empty(); }
const std::vector<GeoFeature>& geography() const { return geoFeatures; }
const std::vector<int>& cellLand() const { return sCellLand; } // continent/island feature index (-1)

View File

@ -40,7 +40,9 @@ void Planet::generateGeography() {
sCellLand.assign(n, -1); sCellWater.assign(n, -1);
sCellRange.assign(n, -1); sCellRiver.assign(n, -1);
std::set<std::string> usedNames;
// Dedupe on the PROPER NOUN (the base, before the kind suffix), so no two features share a root --
// a continent "Karn", a "Karn River" and the "Karn Mountains" would read as the same name.
std::set<std::string> usedNouns;
auto formatName = [](FeatureKind k, const std::string& p) -> std::string {
switch (k) {
case FeatureKind::Ocean: return p + " Ocean";
@ -60,11 +62,11 @@ void Planet::generateGeography() {
return h ? h : 1u;
};
auto makeFeatureName = [&](FeatureKind k, uint32_t seed, int bank) {
std::string nm = formatName(k, namegen::makeName(seed, bank));
for (int guard = 0; usedNames.count(nm) && guard < 64; ++guard)
nm = formatName(k, namegen::makeName(seed += 0x9E3779B9u, bank));
usedNames.insert(nm);
return nm;
std::string p = namegen::makeName(seed, bank);
for (int guard = 0; usedNouns.count(p) && guard < 128; ++guard)
p = namegen::makeName(seed += 0x9E3779B9u, bank);
usedNouns.insert(p);
return formatName(k, p);
};
auto bankOf = [&](int regionId, int fallbackAnchor) {
return regionId >= 0 ? namegen::bankForRegion(cfg.seed, regionId)
@ -118,14 +120,63 @@ void Planet::generateGeography() {
for (int i : comp) sCellLand[i] = fi;
}
// --- Water bodies -> Ocean / Sea -------------------------------------------------------------
auto water = components([&](int i) { return cells[i].elevation <= sea; });
std::sort(water.begin(), water.end(), bySizeDesc);
for (auto& comp : water) {
int anchor = centroidCell(comp);
FeatureKind k = (int)comp.size() <= cfg.geoSeaMaxCells ? FeatureKind::Sea : FeatureKind::Ocean;
int fi = addFeature(k, anchor, -1, (int)comp.size(), bankOf(-1, anchor));
for (int i : comp) sCellWater[i] = fi;
// --- Water bodies -> Ocean basins / Seas -----------------------------------------------------
// A single connected world ocean is unrealistic as one name (Earth's oceans are arbitrary
// subdivisions of one connected body). So partition the ocean into BASINS: distance-from-land
// gives each ocean cell its depth-from-coast; pick well-separated deep cells as basin centres
// (greedy, farthest-first by angular separation); multi-source BFS over the ocean graph assigns
// every ocean cell to its nearest centre. Each basin -> a named Ocean (or Sea if small). On a
// one-basin waterworld this yields a single ocean; on an Earth-like world, several.
{
std::vector<int> dLand(n, -1), frontier, nextf;
for (int i = 0; i < n; ++i) if (cells[i].elevation > sea) { dLand[i] = 0; frontier.push_back(i); }
for (int r = 1; !frontier.empty(); ++r) {
nextf.clear();
for (int i : frontier) for (int j : cells[i].neighbors)
if (dLand[j] < 0) { dLand[j] = r; nextf.push_back(j); }
frontier.swap(nextf);
}
std::vector<int> oc;
for (int i = 0; i < n; ++i) if (cells[i].elevation <= sea) oc.push_back(i);
std::sort(oc.begin(), oc.end(), [&](int a, int b) { return dLand[a] > dLand[b]; }); // deepest first
std::vector<int> centres;
const double sepCos = std::cos(std::max(0.05, cfg.geoOceanSepRadians));
for (int i : oc) {
if (dLand[i] < cfg.geoOceanDeep) break; // remaining cells are coastal (shallow)
bool ok = true;
for (int s : centres) if (cells[i].unit.dot(cells[s].unit) > sepCos) { ok = false; break; }
if (ok) centres.push_back(i);
}
std::vector<int> basin(n, -1), bfront, bnext;
for (size_t s = 0; s < centres.size(); ++s) { basin[centres[s]] = (int)s; bfront.push_back(centres[s]); }
while (!bfront.empty()) { // multi-source Voronoi over the ocean graph
bnext.clear();
for (int i : bfront) for (int j : cells[i].neighbors)
if (cells[j].elevation <= sea && basin[j] < 0) { basin[j] = basin[i]; bnext.push_back(j); }
bfront.swap(bnext);
}
int nextBasin = (int)centres.size();
for (int i = 0; i < n; ++i) { // shallow/enclosed water with no deep centre -> its own Sea
if (cells[i].elevation > sea || basin[i] >= 0) continue;
int b = nextBasin++;
std::vector<int> st{ i }; basin[i] = b;
while (!st.empty()) {
int u = st.back(); st.pop_back();
for (int j : cells[u].neighbors)
if (cells[j].elevation <= sea && basin[j] < 0) { basin[j] = b; st.push_back(j); }
}
}
std::vector<std::vector<int>> mem(nextBasin);
for (int i = 0; i < n; ++i) if (cells[i].elevation <= sea && basin[i] >= 0) mem[basin[i]].push_back(i);
std::vector<int> order(nextBasin); for (int b = 0; b < nextBasin; ++b) order[b] = b;
std::sort(order.begin(), order.end(), [&](int a, int b) { return mem[a].size() > mem[b].size(); });
for (int b : order) {
if (mem[b].empty()) continue;
int anchor = centroidCell(mem[b]);
FeatureKind k = (int)mem[b].size() <= cfg.geoSeaMaxCells ? FeatureKind::Sea : FeatureKind::Ocean;
int fi = addFeature(k, anchor, -1, (int)mem[b].size(), bankOf(-1, anchor));
for (int i : mem[b]) sCellWater[i] = fi;
}
}
// --- Lakes (inland filled basins above sea level) --------------------------------------------
@ -194,3 +245,34 @@ void Planet::generateGeography() {
}
}
}
// Name a cell that has just become land (a volcanic island breaching the sea). Joins an adjacent
// existing landmass if there is one; otherwise mints a new Island feature with a unique name. Returns
// the land feature's name. Appended to geoFeatures + sCellLand (saved). Uses the per-cell hash, not a
// global pass, so it never re-extracts the whole atlas.
std::string Planet::nameNewLand(int cell) {
if (geoFeatures.empty()) return ""; // no atlas yet -> nothing to extend
if (cell < 0 || cell >= (int)cells.size()) return "";
if ((int)sCellLand.size() != (int)cells.size()) return "";
if (sCellLand[cell] >= 0 && sCellLand[cell] < (int)geoFeatures.size())
return geoFeatures[sCellLand[cell]].name; // already part of a named landmass
for (int j : cells[cell].neighbors) { // adjacent to existing land -> join it
int lf = sCellLand[j];
if (lf >= 0 && lf < (int)geoFeatures.size()) { sCellLand[cell] = lf; return geoFeatures[lf].name; }
}
// Isolated new island: mint a unique name (dedup against existing feature names).
int bank = namegen::bankForRegion(cfg.seed, 1000 + cell);
uint32_t seed = (cfg.seed ^ 0x6E0C12A7u) + (uint32_t)(cell * 2654435761u)
+ (uint32_t)geoFeatures.size() * 40503u;
auto inUse = [&](const std::string& nm) {
for (const GeoFeature& f : geoFeatures) if (f.name == nm) return true; return false;
};
std::string nm = namegen::makeName(seed, bank);
for (int g = 0; inUse(nm) && g < 128; ++g) nm = namegen::makeName(seed += 0x9E3779B9u, bank);
int fi = (int)geoFeatures.size();
GeoFeature f; f.id = (uint32_t)fi + 1; f.kind = FeatureKind::Island;
f.anchorCell = cell; f.regionId = fi; f.size = 1; f.name = nm;
geoFeatures.push_back(std::move(f));
sCellLand[cell] = fi;
return geoFeatures[fi].name;
}

View File

@ -48,12 +48,13 @@
D(volcanoExplodeDropFrac) D(volcanoActivityDecay) D(volcanoDeadActivity) \
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
D(geoMountainElev) D(geoRiverMinDischarge) \
D(geoMountainElev) D(geoRiverMinDischarge) D(geoOceanSepRadians) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed)
@ -246,6 +247,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling"));
E(rng(cfg.geoMountainElev, 0.0, 12000.0, "geoMountainElev"));
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -265,6 +267,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.geoRangeMinCells, 1, 1000000, "geoRangeMinCells"));
E(irng(cfg.geoMaxRivers, 0, 100000, "geoMaxRivers"));
E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks"));
E(irng(cfg.geoOceanDeep, 1, 1000, "geoOceanDeep"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));

View File

@ -366,7 +366,9 @@ struct PlanetConfig {
// on a settled world (key M), saved (v17). Tune to control what counts as a continent vs island,
// an ocean vs sea, a named mountain range / major river, and to cap label clutter.
int geoContinentMinCells = 40; // land component >= this many cells = Continent (else Island)
int geoSeaMaxCells = 60; // ocean component <= this many cells = Sea (else Ocean)
int geoSeaMaxCells = 60; // ocean/basin <= this many cells = Sea (else Ocean)
double geoOceanSepRadians = 1.40; // min angular separation between ocean-basin centres (radians; ~4-6 oceans)
int geoOceanDeep = 4; // min rings from land for a cell to seed an ocean basin
double geoMountainElev = 2500.0;// m: min elevation for mountain-range membership
int geoRangeMinCells = 4; // min cells for a named mountain range
double geoRiverMinDischarge = 80.0; // min mouth discharge for a named river

View File

@ -121,6 +121,27 @@ int main() {
}
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());
@ -158,6 +179,30 @@ int main() {
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;
}