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

View File

@ -53,6 +53,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue) O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
K toggle weather clouds/rain cover (Live World) K toggle weather clouds/rain cover (Live World)
V toggle volcano markers (Live World; cones + eruption glow, build into islands) V toggle volcano markers (Live World; cones + eruption glow, build into islands)
M toggle place-name labels (names continents/oceans/ranges/rivers/lakes; Atlas tab)
Y follow-cam: cycle the 3D camera through active storms (Live World; off after last) Y follow-cam: cycle the 3D camera through active storms (Live World; off after last)
. / , step the live clock forward / back by one rate-unit (auto-pauses; back also . / , step the live clock forward / back by one rate-unit (auto-pauses; back also
rewinds weather + storms via an undo history) rewinds weather + storms via an undo history)
@ -77,7 +78,8 @@ CLI flags (applied before the first load/generate):
planet.cfg human-editable key=value config of every PlanetConfig parameter; planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten). load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v16: +event log; v15 +stateful planet.save binary snapshot (versioned, currently v17: +geography/atlas; v16
+event log; v15 +stateful
volcanoes; v13 +Live World clock rate; v12 +step-back history volcanoes; v13 +Live World clock rate; v12 +step-back history
(~40 frames, so a load can rewind storms); v11 +weather systems/storms; (~40 frames, so a load can rewind storms); v11 +weather systems/storms;
v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5 v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5
@ -261,6 +263,18 @@ weaker. Step-back snapshots include volcano state. Saved v15+.
volcanoAshCloud 0.9 cloud cover injected by sustained ash volcanoAshCloud 0.9 cloud cover injected by sustained ash
volcanoAshCooling 6 C peak local cooling under an active ash plume volcanoAshCooling 6 C peak local cooling under an active ash plume
Geography / atlas (PlanetConfig, key M): extract + name geographic features from the frozen
terrain (continents, islands, oceans, seas, lakes, mountain ranges, peaks, rivers). Generated
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)
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
geoMaxRivers 40 cap on named rivers (largest by discharge)
geoMaxPeaks 40 cap on named peaks (highest)
## Headless logic test (no display) ## Headless logic test (no display)
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
@ -270,10 +284,12 @@ weaker. Step-back snapshots include volcano state. Saved v15+.
src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetVolcano.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetVolcano.cpp \
src/sim/PlanetBiota.cpp \ src/sim/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.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/t && /tmp/t src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# Biota / Live World / Ocean / Weather / Volcano suites: same source list, swap test_logic.cpp -> # Biota / Live World / Ocean / Weather / Volcano / Geography suites: same source list, swap
# test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp or test_volcano.cpp. # test_logic.cpp -> test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp,
# test_volcano.cpp or test_geography.cpp.
# The CMake build also includes test_events for the viewer event journal. # The CMake build also includes test_events for the viewer event journal.
Verifies geometry, plate assignment, gradual non-saturating relief and Verifies geometry, plate assignment, gradual non-saturating relief and

View File

@ -105,6 +105,24 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
`,`/`.` reverses height, dormancy, explosions and ash timers. Rendered as growing, dormant and `,`/`.` reverses height, dormancy, explosions and ash timers. Rendered as growing, dormant and
post-explosion cone markers (3D + 2D, key `V`); saved (v15). Knobs `volcano*`. post-explosion cone markers (3D + 2D, key `V`); saved (v15). Knobs `volcano*`.
**Civilizations (in progress — the long arc after the world is finished):** the eventual goal is
people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural +
geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
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
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*`.
*Next steps (not yet built): settlements + food/habitability, territory + borders, culture +
beliefs, conflict + diplomacy.*
## Current state ## Current state
Working and verified (logic tested headless): Working and verified (logic tested headless):
@ -509,6 +527,8 @@ src/
PlanetFloraGen.cpp computeFloraDensity + fillFlora PlanetFloraGen.cpp computeFloraDensity + fillFlora
PlanetFaunaGen.cpp computeFaunaDensity + fillFauna (carnivores gated on prey) PlanetFaunaGen.cpp computeFaunaDensity + fillFauna (carnivores gated on prey)
PlanetFungiGen.cpp computeFungaDensity + fillFunga (moisture/organic-matter rule) PlanetFungiGen.cpp computeFungaDensity + fillFunga (moisture/organic-matter rule)
NameGen.* deterministic procedural name generator (syllable banks; reused by civ arc)
PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes)
PlanetIO.cpp config file (text) + binary save/load PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer) render/ (raylib viewer)
Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota) Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota)
@ -571,11 +591,13 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetVolcano.cpp \ src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetVolcano.cpp \
src/sim/PlanetBiota.cpp \ src/sim/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.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/t && /tmp/t src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp` or `test_volcano.cpp` to run the Biota / Live World / Ocean / Weather / `test_weather.cpp`, `test_volcano.cpp` or `test_geography.cpp` to run the Biota / Live World / Ocean /
Volcano suites — same source list. CMake also builds `test_events` for the viewer event journal.) Weather / Volcano / Geography suites — same source list. CMake also builds `test_events` for the
viewer event journal.)
Use this to verify tectonics after changing `Planet::step()` without launching Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without the window (the engine lives in `src/sim` and is raylib-free, so it links without
@ -607,7 +629,7 @@ active mode shown top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3, `B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) · all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
`O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) · `O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
`V` volcano markers (Live World) · `V` volcano markers (Live World) · `M` place-name labels (the atlas; names the world on first use) ·
`SPACE` or on-screen button pause · `SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) · `[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock `S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock
@ -647,7 +669,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is `Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **14**; v2 adds the `[`/`]` drift rate, v3 a save header is versioned (currently **17**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population** key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
@ -656,7 +678,8 @@ humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG
active storms, v12 appends the most recent **step-back frames**`wxSaveMax`(40) weather snapshots active storms, v12 appends the most recent **step-back frames**`wxSaveMax`(40) weather snapshots
— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate, v14 — so a load can rewind storms past the saved moment, v13 appends the Live World clock rate, v14
appends the old pure-function **volcanoes** block, v15 replaces it with stateful volcano lifecycle appends the old pure-function **volcanoes** block, v15 replaces it with stateful volcano lifecycle
agents plus volcano state in step-back frames, and v16 appends the saved **event journal**; agents plus volcano state in step-back frames, v16 appends the saved **event journal**, and v17
appends the **geography/atlas** block — named features + per-cell region indices;
newer-than-supported is newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
@ -664,7 +687,8 @@ saves spin weather up live; pre-v11 saves load with no active storms (they respa
load with no step-back history (you can still step forward then back); pre-v13 saves resume with load with no step-back history (you can still step forward then back); pre-v13 saves resume with
the default live clock rate; pre-v14 saves load with no volcanoes (placed on the next Live World the default live clock rate; pre-v14 saves load with no volcanoes (placed on the next Live World
entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with old history skipped; entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with old history skipped;
pre-v16 saves load with an empty event journal. pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
demand via `M`).
A load drops any **stale** pre-load `wxUndo` history and reloads the A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one. saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
@ -786,6 +810,13 @@ triangles (plates are fixed in phase 1).
`volcanoBlastRadius` (0.09 rad), `volcanoBlastCloud` (1.5), `volcanoAshMinYears`/`MaxYears` `volcanoBlastRadius` (0.09 rad), `volcanoBlastCloud` (1.5), `volcanoAshMinYears`/`MaxYears`
(0.5/3), `volcanoAshPuffCellsPerWeek` (2), `volcanoAshCloud` (0.9), and (0.5/3), `volcanoAshPuffCellsPerWeek` (2), `volcanoAshCloud` (0.9), and
`volcanoAshCooling` (6 °C). Marker sizes/colours are render constants (ViewerRender.cpp). `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
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.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -32,6 +32,8 @@ set(SIM_SOURCES
src/sim/PlanetFloraGen.cpp src/sim/PlanetFloraGen.cpp
src/sim/PlanetFaunaGen.cpp src/sim/PlanetFaunaGen.cpp
src/sim/PlanetFungiGen.cpp src/sim/PlanetFungiGen.cpp
src/sim/NameGen.cpp
src/sim/PlanetGeography.cpp
src/sim/PlanetIO.cpp src/sim/PlanetIO.cpp
) )
@ -70,7 +72,7 @@ if(UNIX AND NOT APPLE)
endif() endif()
enable_testing() enable_testing()
foreach(test_name logic biota ocean live weather volcano) foreach(test_name logic biota ocean live weather volcano geography)
add_executable(test_${test_name} test_${test_name}.cpp) add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim) target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name}) add_test(NAME ${test_name} COMMAND test_${test_name})

View File

@ -43,6 +43,11 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
`computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga). `computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga).
- `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density + - `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density +
per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led). per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led).
- `PlanetVolcano.cpp``placeVolcanoes`/`stepVolcanoes` (Live World stateful volcano lifecycle +
islands; saved v15).
- `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc).
- `PlanetGeography.{hpp,cpp}``generateGeography()` (named features: continents/oceans/ranges/
rivers/lakes; the atlas, saved v17).
- `PlanetIO.cpp` — text config + binary save/load. - `PlanetIO.cpp` — text config + binary save/load.
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration), The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
@ -294,6 +299,31 @@ volcano eruptions, and submarine volcanoes breaching into islands. Clicking an e
cell using the same axial-tilt convention as picking, and centre the 2D map at the current zoom. cell using the same axial-tilt convention as picking, and centre the 2D map at the current zoom.
Save **v16** appends the event log; pre-v16 saves load with an empty journal. Save **v16** appends the event log; pre-v16 saves load with an empty journal.
## Geography & place-names — the atlas (civilization Step 1)
`PlanetGeography.cpp` + `NameGen.cpp` (engine, raylib-free, deterministic). The first step of the
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** (≤
`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
river/lake fields exist on a finished world.
Naming is a separate concern in `NameGen` (syllable banks; `bankForRegion` gives each continent a
"language" so its rivers/mountains share a sound) and uses a separate RNG (`sGeoRng = cfg.seed ^
magic`) + a per-feature hash, so it is deterministic and **never perturbs the tectonic stream**
(asserted in `test_geography.cpp`). Output: `Planet::geoFeatures` (id/kind/name/anchorCell/regionId/
size) plus four per-cell index arrays (`sCellLand`/`sCellWater`/`sCellRange`/`sCellRiver`) giving O(1)
"which features is this cell in" — the hook the later territory/border step will build on. Geography is
static (terrain is frozen), so it is generated **once** on a settled world (key `M`, in or out of Live
World) and **saved (v17)** — names persist so a future culture step can rename places. The viewer draws
names as labels on the globe (the plate-label manual projection) + 2D map (minor features only when
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.
## Headless testing ## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run: Engine is raylib-free, so logic is tested without a display. Build/run:
@ -302,9 +332,11 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet
src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.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/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.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/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp src/sim/PlanetGeography.cpp \
# test_biota.cpp uses the same source list (Biota suite). src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography use the same list.
``` ```
(add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are (add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are
data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism). data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).

View File

@ -46,6 +46,21 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
pl.type == PlateType::Oceanic ? "Oceanic" : "Continental", pl.type == PlateType::Oceanic ? "Oceanic" : "Continental",
c.oceanic ? "Oceanic" : "Continental"))); c.oceanic ? "Oceanic" : "Continental")));
L.push_back(std::string(TextFormat("biome: %s", biomeName(c.biome)))); L.push_back(std::string(TextFormat("biome: %s", biomeName(c.biome))));
// Geography (the atlas): which named features this cell belongs to.
if (p.geographyBuilt()) {
const auto& F = p.geography();
auto nameOf = [&](const std::vector<int>& arr) -> const char* {
int fi = (i < (int)arr.size()) ? arr[i] : -1;
return (fi >= 0 && fi < (int)F.size()) ? F[fi].name.c_str() : nullptr;
};
const char* loc = nameOf(c.elevation > p.cfg.seaLevel ? p.cellLand() : p.cellWater());
if (loc) L.push_back(std::string("region: ") + loc);
if (const char* rg = nameOf(p.cellRange())) L.push_back(std::string(" ") + rg);
if (const char* rv = nameOf(p.cellRiver())) L.push_back(std::string(" on the ") + rv);
// A lake cell sits on land but its water feature is the lake.
const char* lk = (c.elevation > p.cfg.seaLevel) ? nameOf(p.cellWater()) : nullptr;
if (lk) L.push_back(std::string(" ") + lk);
}
// Climate (derived; present once computeClimate() has run). // Climate (derived; present once computeClimate() has run).
if (sized(p.temperature()) && sized(p.moisture())) if (sized(p.temperature()) && sized(p.moisture()))
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%", L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",

View File

@ -425,7 +425,7 @@ void Viewer::loadGame(const char* path) {
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography
cfg = planet.cfg; // adopt the loaded config cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0); elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift planet.drifting = settled; // resume drift boosts iff mid-drift

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 16; // v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr uint32_t SAVE_VERSION = 17; // v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save
static constexpr int EVENT_LOG_MAX = 200; static constexpr int EVENT_LOG_MAX = 200;
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";
@ -99,6 +99,8 @@ struct Viewer {
bool showCurrents = false; // ocean current arrows, warm/cold (key O) bool showCurrents = false; // ocean current arrows, warm/cold (key O)
bool showClouds = true; // Live World cloud/rain cover overlay (key K) bool showClouds = true; // Live World cloud/rain cover overlay (key K)
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V) bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V)
bool showNames = false; // geographic place-name labels (the atlas, key M)
std::vector<int> atlasRowCells; // cell to focus per visible Atlas-tab row (parallel to the list)
// World event journal: currently Live World events, shaped to be reused by later phases. // World event journal: currently Live World events, shaped to be reused by later phases.
struct WorldEvent { struct WorldEvent {
@ -112,7 +114,7 @@ struct Viewer {
}; };
std::vector<WorldEvent> events; std::vector<WorldEvent> events;
uint32_t nextEventId = 1; uint32_t nextEventId = 1;
int liveInfoTab = 0; // 0 Sky, 1 Tides, 2 Weather, 3 Events int liveInfoTab = 0; // 0 Sky, 1 Tides, 2 Weather, 3 Events, 4 Atlas
std::vector<Rectangle> liveInfoTabRects; std::vector<Rectangle> liveInfoTabRects;
std::vector<Rectangle> eventRowRects; std::vector<Rectangle> eventRowRects;
std::vector<int> eventRowIndices; // indices into events for visible event rows std::vector<int> eventRowIndices; // indices into events for visible event rows

View File

@ -44,6 +44,12 @@ void Viewer::handleInput() {
focusCell(events[ei].cell, events[ei].title); focusCell(events[ei].cell, events[ei].title);
break; break;
} }
} else if (liveInfoTab == 4) { // Atlas: click a feature row to fly there
for (size_t i = 0; i < eventRowRects.size() && i < atlasRowCells.size(); ++i) {
if (!CheckCollisionPointRec(mp, eventRowRects[i])) continue;
if (atlasRowCells[i] >= 0) focusCell(atlasRowCells[i], "");
break;
}
} }
} }
} }
@ -188,6 +194,7 @@ void Viewer::handleInput() {
if (liveWorld) { if (liveWorld) {
phase3Prompt = false; paused = false; wxUndo.clear(); phase3Prompt = false; paused = false; wxUndo.clear();
if (planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); // one-time tectonic-context placement if (planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); // one-time tectonic-context placement
if (!planet.geographyBuilt()) planet.generateGeography(); // name the world's geography (the atlas)
refreshView(); // fresh base colours; overlay builds in stepSim refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started"); setStatus("Live World started");
} else { } else {
@ -217,6 +224,11 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover
if (IsKeyPressed(KEY_V)) showVolcanoes = !showVolcanoes; // toggle volcano markers (Live World) if (IsKeyPressed(KEY_V)) showVolcanoes = !showVolcanoes; // toggle volcano markers (Live World)
if (IsKeyPressed(KEY_M) && settled) { // toggle place-name labels (the atlas)
if (!planet.geographyBuilt()) planet.generateGeography(); // lazily name the world on first use
showNames = !showNames;
setStatus(showNames ? "Place names on" : "Place names off");
}
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); } if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); } if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { // one step if (IsKeyPressed(KEY_S)) { // one step

View File

@ -9,6 +9,24 @@
#include <cmath> #include <cmath>
#include <vector> #include <vector>
// Label style for a geographic feature: font size + colour, returns true if it's a "minor" feature
// (peaks/rivers/lakes/seas/small islands) -- those are drawn only when zoomed in, to declutter.
static bool labelStyle(const GeoFeature& f, int& font, Color& col) {
const Color land{215, 220, 235, 255}, water{120, 195, 230, 255},
river{110, 175, 235, 255}, mtn{220, 195, 150, 255};
switch (f.kind) {
case FeatureKind::Continent: font = 20; col = land; return false;
case FeatureKind::Ocean: font = 18; col = water; return false;
case FeatureKind::Sea: font = 15; col = water; return true;
case FeatureKind::Island: font = f.size >= 8 ? 15 : 13; col = land; return f.size < 8;
case FeatureKind::MountainRange: font = 15; col = mtn; return false;
case FeatureKind::Peak: font = 13; col = mtn; return true;
case FeatureKind::River: font = 13; col = river; return true;
case FeatureKind::Lake: font = 13; col = water; return true;
}
font = 13; col = land; return true;
}
// Render the 3D globe into its own RenderTexture (its viewport != the screen). // Render the 3D globe into its own RenderTexture (its viewport != the screen).
void Viewer::renderGlobe3D() { void Viewer::renderGlobe3D() {
BeginTextureMode(rt3d); BeginTextureMode(rt3d);
@ -325,6 +343,21 @@ void Viewer::renderMap2D() {
DrawText(txt, (int)lp.x + 4, (int)lp.y - 8, 12, RAYWHITE); DrawText(txt, (int)lp.x + 4, (int)lp.y - 8, 12, RAYWHITE);
} }
} }
// Place-name labels (the atlas). Minor features only when the map is zoomed in.
if (showNames && planet.geographyBuilt()) {
bool zoomed = mapZoom > 1.5;
for (const GeoFeature& f : planet.geography()) {
if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue;
int font; Color col; bool minor = labelStyle(f, font, col);
if (font <= 0 || (minor && !zoomed)) continue;
double lon, lat; dirToLonLat(planet.cells[f.anchorCell].unit, lon, lat);
Vector2 lp = projLonLat(lon, lat, mapLon, vr);
if (!CheckCollisionPointRec(lp, mapRect)) continue;
int w = MeasureText(f.name.c_str(), font);
DrawText(f.name.c_str(), (int)lp.x - w / 2 + 1, (int)lp.y - font / 2 + 1, font, Color{0, 0, 0, 180});
DrawText(f.name.c_str(), (int)lp.x - w / 2, (int)lp.y - font / 2, font, col);
}
}
if (selectedCell >= 0) DrawCircleV(mapScreen(map2D, selectedCell, vr, mapLon), 5, ORANGE); if (selectedCell >= 0) DrawCircleV(mapScreen(map2D, selectedCell, vr, mapLon), 5, ORANGE);
if (hovered >= 0) DrawCircleV(mapScreen(map2D, hovered, vr, mapLon), 4, YELLOW); if (hovered >= 0) DrawCircleV(mapScreen(map2D, hovered, vr, mapLon), 4, YELLOW);
EndScissorMode(); EndScissorMode();
@ -336,17 +369,17 @@ void Viewer::renderMap2D() {
// Live World tabbed info panel in the freed space right of the (left-aligned) 2D map. // Live World tabbed info panel in the freed space right of the (left-aligned) 2D map.
void Viewer::renderLiveInfo() { void Viewer::renderLiveInfo() {
liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear(); liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear(); atlasRowCells.clear();
if (!liveWorld) return; if (!liveWorld) return;
Rectangle r = liveInfoRect; Rectangle r = liveInfoRect;
DrawRectangleRec(r, Color{10, 12, 20, 235}); DrawRectangleRec(r, Color{10, 12, 20, 235});
DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255}); DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255});
int x = (int)r.x + 14, y = (int)r.y + 10; int x = (int)r.x + 14, y = (int)r.y + 10;
DrawText("Live info", x, y, 20, RAYWHITE); DrawText("Live info", x, y, 20, RAYWHITE);
const char* tabs[4] = { "Sky", "Tides", "Weather", "Events" }; const char* tabs[5] = { "Sky", "Tides", "Weather", "Events", "Atlas" };
float tx = r.x + 10.0f, ty = r.y + 38.0f; float tx = r.x + 10.0f, ty = r.y + 38.0f;
for (int i = 0; i < 4; ++i) { for (int i = 0; i < 5; ++i) {
float tw = (r.width - 20.0f) / 4.0f; float tw = (r.width - 20.0f) / 5.0f;
Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f }; Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f };
liveInfoTabRects.push_back(tr); liveInfoTabRects.push_back(tr);
bool on = liveInfoTab == i; bool on = liveInfoTab == i;
@ -445,7 +478,7 @@ void Viewer::renderLiveInfo() {
lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c); lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c);
y += 21; ++shown; y += 21; ++shown;
} }
} else { } else if (liveInfoTab == 3) {
DrawText("World events", x, y, 18, Color{200, 205, 220, 255}); DrawText("World events", x, y, 18, Color{200, 205, 220, 255});
DrawText(TextFormat("%d saved", (int)events.size()), (int)(r.x + r.width) - 74, y + 2, 13, Color{145, 155, 175, 255}); DrawText(TextFormat("%d saved", (int)events.size()), (int)(r.x + r.width) - 74, y + 2, 13, Color{145, 155, 175, 255});
y += 28; y += 28;
@ -474,6 +507,43 @@ void Viewer::renderLiveInfo() {
y += 43; y += 43;
} }
} }
} else { // Atlas: named geographic features, grouped by kind; click a row to fly there
const auto& F = planet.geography();
DrawText("Atlas", x, y, 18, Color{200, 205, 220, 255});
DrawText(TextFormat("%d named", (int)F.size()), (int)(r.x + r.width) - 78, y + 2, 13, Color{145, 155, 175, 255});
y += 26;
if (F.empty()) {
DrawText(planet.geographyBuilt() ? "(none)" : "press M to name the world", x, y, 14, Color{150, 155, 170, 255});
} else {
// Order kinds for a readable list; within a kind, largest first.
const FeatureKind order[8] = { FeatureKind::Continent, FeatureKind::Island, FeatureKind::Ocean,
FeatureKind::Sea, FeatureKind::MountainRange, FeatureKind::Peak, FeatureKind::River, FeatureKind::Lake };
auto kindColor = [](FeatureKind k) -> Color {
switch (k) {
case FeatureKind::Ocean: case FeatureKind::Sea: case FeatureKind::Lake: return Color{120, 195, 230, 255};
case FeatureKind::River: return Color{110, 175, 235, 255};
case FeatureKind::MountainRange: case FeatureKind::Peak: return Color{220, 195, 150, 255};
default: return Color{215, 220, 235, 255};
}
};
for (FeatureKind k : order) {
std::vector<int> idx;
for (int i = 0; i < (int)F.size(); ++i) if (F[i].kind == k) idx.push_back(i);
if (idx.empty()) continue;
std::sort(idx.begin(), idx.end(), [&](int a, int b){ return F[a].size > F[b].size; });
if (y > (int)(r.y + r.height) - 22) break;
DrawText(featureKindName(k), x, y, 13, Color{150, 158, 178, 255});
y += 18;
for (int i : idx) {
if (y > (int)(r.y + r.height) - 18) break;
Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 18.0f };
eventRowRects.push_back(row); atlasRowCells.push_back(F[i].anchorCell);
DrawText(F[i].name.c_str(), (int)row.x + 8, (int)row.y + 1, 14, kindColor(k));
y += 19;
}
y += 4;
}
}
} }
} }
@ -550,8 +620,8 @@ void Viewer::renderHUD() {
y += 8; y += 8;
line("hover: cell info | click tile: open detail panel | C close"); line("hover: cell info | click tile: open detail panel | C close");
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)"); line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s] | V volcanoes [%s]", line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s] | V volcanoes [%s] | M names [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off")); showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-", line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off")); phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg"); line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");
@ -624,6 +694,37 @@ void Viewer::renderFrame() {
} }
} }
// 3D place-name labels (the atlas), same manual projection. Minor features (peaks/rivers/lakes/
// small islands) only show when zoomed in, to keep the default view readable.
if (showNames && planet.geographyBuilt()) {
Vec3 camPos{cam.position.x, cam.position.y, cam.position.z};
Vec3 camTgt{cam.target.x, cam.target.y, cam.target.z};
Vec3 camUp {cam.up.x, cam.up.y, cam.up.z};
Vec3 forward = (camTgt - camPos).normalized();
Vec3 right = forward.cross(camUp).normalized();
Vec3 up = right.cross(forward);
double fovRad = cam.fovy * M_PI / 180.0;
double aspect = (double)view3DW / view3DH;
double projH = std::tan(fovRad * 0.5), projW = projH * aspect;
bool zoomed = camDist < 5.5;
for (const GeoFeature& f : planet.geography()) {
if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue;
int font; Color col; bool minor = labelStyle(f, font, col);
if (font <= 0 || (minor && !zoomed)) continue;
const Cell& c = planet.cells[f.anchorCell];
double sr = visBase + (double)c.elevation * elevExagg + 0.01;
Vec3 lp = rotateZ(c.unit, planet.cfg.axialTilt) * sr;
if (lp.dot(camPos) <= 0.0) continue; // far hemisphere
Vec3 rel = lp - camPos; double z = rel.dot(forward);
if (z <= 0.0) continue;
float sx = (float)((rel.dot(right) / (projW * z) * 0.5 + 0.5) * view3DW);
float sy = (float)((0.5 - rel.dot(up) / (projH * z) * 0.5) * view3DH);
int w = MeasureText(f.name.c_str(), font);
DrawText(f.name.c_str(), (int)sx - w / 2 + 1, (int)sy - font / 2 + 1, font, Color{0, 0, 0, 180});
DrawText(f.name.c_str(), (int)sx - w / 2, (int)sy - font / 2, font, col);
}
}
renderMap2D(); renderMap2D();
renderLiveInfo(); renderLiveInfo();
renderPanels(); renderPanels();

81
src/sim/NameGen.cpp Normal file
View File

@ -0,0 +1,81 @@
#include "NameGen.hpp"
#include <array>
#include <vector>
#include <cctype>
// Procedural namer. xorshift32 (same family as Planet::rnd / volNext) seeded per call, so names are
// deterministic and independent of any global RNG state. Each "bank" is a set of syllable parts;
// a syllable = onset + nucleus + (optional) coda, and a name is 2-3 syllables, capitalised.
namespace {
struct Bank { std::vector<const char*> onset, nucleus, coda; };
// Four loosely-flavoured phoneme banks ("languages"). Empty strings give vowel-initial syllables /
// open syllables. Kept deliberately small and pronounceable.
const std::array<Bank, 4>& banks() {
static const std::array<Bank, 4> B = {{
// 0 -- soft / liquid
{ {"", "b", "d", "l", "m", "n", "r", "s", "th", "v", "br", "dr"},
{"a", "e", "i", "o", "u", "ae", "ia", "eo"},
{"", "", "n", "r", "l", "s", "th"} },
// 1 -- harsh / guttural
{ {"k", "kr", "g", "gr", "t", "tr", "dr", "z", "kh", "vr", "k", "g"},
{"a", "o", "u", "au", "ou", "a", "o"},
{"", "k", "g", "r", "rk", "th", "kh"} },
// 2 -- flowing / coastal
{ {"", "l", "m", "n", "s", "sh", "w", "y", "ll", "th", "n", "m"},
{"a", "e", "i", "ai", "ei", "ia", "io", "ee"},
{"", "", "n", "l", "s", ""} },
// 3 -- earthen / sturdy
{ {"", "b", "d", "g", "m", "t", "v", "br", "gr", "th", "d", "b"},
{"a", "o", "e", "oo", "uo", "a", "o"},
{"", "m", "n", "g", "r", "rd"} },
}};
return B;
}
inline uint32_t nx(uint32_t& s) { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return s; }
template <class T> const T& pick(const std::vector<T>& v, uint32_t& s) { return v[nx(s) % v.size()]; }
} // namespace
namespace namegen {
int bankCount() { return (int)banks().size(); }
int bankForRegion(uint32_t worldSeed, int regionId) {
uint32_t h = worldSeed ^ (uint32_t)(regionId * 2654435761u + 0x9E3779B9u);
h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15;
return (int)(h % (uint32_t)bankCount());
}
std::string makeName(uint32_t seed, int bank, int minSyl, int maxSyl) {
if (bank < 0 || bank >= bankCount()) bank = 0;
if (minSyl < 1) minSyl = 1;
if (maxSyl < minSyl) maxSyl = minSyl;
const Bank& b = banks()[(size_t)bank];
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;
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);
}
// Collapse runs of 3+ identical letters to 2 (kills "oooo" / "lll" -> "oo" / "ll").
std::string clean;
for (char ch : out) {
size_t m = clean.size();
if (m >= 2 && clean[m - 1] == ch && clean[m - 2] == ch) continue;
clean += ch;
}
if (clean.empty()) clean = "Or";
clean[0] = (char)std::toupper((unsigned char)clean[0]);
return clean;
}
} // namespace namegen

18
src/sim/NameGen.hpp Normal file
View File

@ -0,0 +1,18 @@
#pragma once
#include <string>
#include <cstdint>
// Deterministic procedural name generator (raylib-free). A small syllable engine reused across the
// civilization arc: geographic features now, settlements / cultures / people later. Output is a
// capitalised proper noun; everything is a pure function of the seed (no global RNG), so the same
// seed always yields the same name. `bank` picks a phoneme set ("language") so a region's places can
// share a sound -- bankForRegion() maps a region id to a stable bank.
namespace namegen {
int bankCount(); // number of phoneme banks ("languages")
int bankForRegion(uint32_t worldSeed, int regionId);
// Build a name of minSyl..maxSyl syllables from `bank`, seeded by `seed`.
std::string makeName(uint32_t seed, int bank, int minSyl = 2, int maxSyl = 3);
}

View File

@ -3,6 +3,7 @@
#include "IcoSphere.hpp" #include "IcoSphere.hpp"
#include "PlanetTypes.hpp" // Cell, Plate, SubGrid/SubCell, PlanetConfig #include "PlanetTypes.hpp" // Cell, Plate, SubGrid/SubCell, PlanetConfig
#include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota #include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota
#include "PlanetGeography.hpp" // FeatureKind, GeoFeature
#include <vector> #include <vector>
#include <memory> #include <memory>
#include <cstdint> #include <cstdint>
@ -16,6 +17,7 @@ public:
std::vector<Plate> plates; std::vector<Plate> plates;
std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved) std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved)
std::vector<Volcano> volcanoes; // Live World: stateful lifecycle volcanoes (saved v15) std::vector<Volcano> volcanoes; // Live World: stateful lifecycle volcanoes (saved v15)
std::vector<GeoFeature> geoFeatures; // named geographic features / the atlas (saved v17)
// Phase flag: false during Phase-1 forming (modest, original tectonics that // Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -141,6 +143,18 @@ public:
const std::vector<double>& discharge() const { return sDischarge; } const std::vector<double>& discharge() const { return sDischarge; }
const std::vector<int>& flowTo() const { return sFlowTo; } const std::vector<int>& flowTo() const { return sFlowTo; }
// Geography stage (the atlas, PlanetGeography.cpp). generateGeography() extracts named
// geographic features from the frozen terrain by connectivity (continents/islands, oceans/seas,
// 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();
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)
const std::vector<int>& cellWater() const { return sCellWater; } // ocean/sea/lake feature index (-1)
const std::vector<int>& cellRange() const { return sCellRange; } // mountain-range feature index (-1)
const std::vector<int>& cellRiver() const { return sCellRiver; } // river feature index (-1)
// Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook). // Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook).
std::shared_ptr<SubGrid> makeSubGrid(int cellIndex, int res) const; std::shared_ptr<SubGrid> makeSubGrid(int cellIndex, int res) const;
@ -156,9 +170,11 @@ public:
// older saves leave weather to spin up on entering Live World. // older saves leave weather to spin up on entering Live World.
// hasVolcanoes: whether the stream carries a volcano block (save v14+). hasStatefulVolcanoes // hasVolcanoes: whether the stream carries a volcano block (save v14+). hasStatefulVolcanoes
// means v15+ lifecycle volcanoes; v14's old pure-function block is consumed and discarded. // means v15+ lifecycle volcanoes; v14's old pure-function block is consumed and discarded.
// hasGeography: whether the stream carries the geography/atlas block (save v17+); older saves load
// with none (regenerated on demand).
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true); bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -244,6 +260,10 @@ private:
// Volcanoes (Live World; saved v15). Separate RNG (seeded from cfg.seed in placeVolcanoes) // Volcanoes (Live World; saved v15). Separate RNG (seeded from cfg.seed in placeVolcanoes)
// keeps tectonic determinism intact while lifecycle rolls happen during Live World. // keeps tectonic determinism intact while lifecycle rolls happen during Live World.
uint32_t sVolRng = 1; uint32_t sVolRng = 1;
// Geography (the atlas; saved v17). Per-cell feature index arrays (-1 = none) + a separate RNG
// so naming never perturbs the tectonic stream.
std::vector<int> sCellLand, sCellWater, sCellRange, sCellRiver;
uint32_t sGeoRng = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded. // on-demand discrete population (saved). sHasBiota latches once generated/loaded.

196
src/sim/PlanetGeography.cpp Normal file
View File

@ -0,0 +1,196 @@
#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cmath>
#include <set>
#include <string>
#include <utility>
// --- Geography stage: the atlas ----------------------------------------------
// Extract named geographic features from the (frozen) terrain by connectivity over the fixed grid,
// the foundation of the civilization arc. All flood-fill / downstream-walk patterns mirror existing
// engine code (coalesceBabyPlates, the enclosed-sea fill, the hydrology flowTo walk). Naming draws a
// SEPARATE RNG (sGeoRng) + the deterministic NameGen, so it never perturbs the tectonic stream.
const char* featureKindName(FeatureKind k) {
switch (k) {
case FeatureKind::Continent: return "Continent";
case FeatureKind::Island: return "Island";
case FeatureKind::Ocean: return "Ocean";
case FeatureKind::Sea: return "Sea";
case FeatureKind::Lake: return "Lake";
case FeatureKind::MountainRange: return "Mountains";
case FeatureKind::Peak: return "Peak";
case FeatureKind::River: return "River";
}
return "?";
}
void Planet::generateGeography() {
const int n = (int)cells.size();
const double sea = cfg.seaLevel;
sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u;
// Rivers/lakes derive from the hydrology fields -- make sure they exist (routing only, no erosion,
// so this changes no elevation and is safe to call on a finished world).
if ((int)sDischarge.size() != n || (int)sLakeDepth.size() != n || (int)sFlowTo.size() != n)
computeHydrology();
geoFeatures.clear();
sCellLand.assign(n, -1); sCellWater.assign(n, -1);
sCellRange.assign(n, -1); sCellRiver.assign(n, -1);
std::set<std::string> usedNames;
auto formatName = [](FeatureKind k, const std::string& p) -> std::string {
switch (k) {
case FeatureKind::Ocean: return p + " Ocean";
case FeatureKind::Sea: return p + " Sea";
case FeatureKind::Lake: return "Lake " + p;
case FeatureKind::MountainRange: return p + " Mountains";
case FeatureKind::Peak: return "Mount " + p;
case FeatureKind::River: return p + " River";
default: return p; // Continent / Island: bare proper noun
}
};
// Per-feature seed: a stable hash of (world seed, kind, anchor cell) -- order-independent.
auto featSeed = [&](FeatureKind k, int anchor) -> uint32_t {
uint32_t h = (cfg.seed ^ 0x6E0C12A7u) + (uint32_t)((int)k) * 0x85EBCA6Bu;
h ^= (uint32_t)(anchor * 2654435761u + 0x165667B1u);
h ^= h >> 15; h *= 0x2545F491u; h ^= h >> 13;
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;
};
auto bankOf = [&](int regionId, int fallbackAnchor) {
return regionId >= 0 ? namegen::bankForRegion(cfg.seed, regionId)
: namegen::bankForRegion(cfg.seed, 1000 + fallbackAnchor);
};
// The label/focus cell: the member nearest the component's (normalized) centroid direction.
auto centroidCell = [&](const std::vector<int>& comp) {
Vec3 c{0, 0, 0};
for (int i : comp) c = c + cells[i].unit;
if (c.length() < 1e-9) return comp.front();
c = c.normalized();
int best = comp.front(); double bd = -2.0;
for (int i : comp) { double d = cells[i].unit.dot(c); if (d > bd) { bd = d; best = i; } }
return best;
};
// Connected components of cells satisfying pred(), via DFS over the fixed neighbour graph.
auto components = [&](auto pred) {
std::vector<std::vector<int>> out;
std::vector<char> seen(n, 0); std::vector<int> stack;
for (int i = 0; i < n; ++i) {
if (seen[i] || !pred(i)) continue;
std::vector<int> comp; stack.clear(); stack.push_back(i); seen[i] = 1;
while (!stack.empty()) {
int u = stack.back(); stack.pop_back(); comp.push_back(u);
for (int v : cells[u].neighbors)
if (!seen[v] && pred(v)) { seen[v] = 1; stack.push_back(v); }
}
out.push_back(std::move(comp));
}
return out;
};
auto bySizeDesc = [](const std::vector<int>& a, const std::vector<int>& b) { return a.size() > b.size(); };
auto addFeature = [&](FeatureKind k, int anchor, int regionId, int size, int bank) -> int {
int fi = (int)geoFeatures.size();
GeoFeature f; f.id = (uint32_t)fi + 1; f.kind = k; f.anchorCell = anchor;
f.regionId = regionId; f.size = size;
f.name = makeFeatureName(k, featSeed(k, anchor), bank);
geoFeatures.push_back(std::move(f));
return fi;
};
// --- Land masses -> Continent / Island (regionId every other feature inherits) ---------------
auto land = components([&](int i) { return cells[i].elevation > sea; });
std::sort(land.begin(), land.end(), bySizeDesc);
for (auto& comp : land) {
int anchor = centroidCell(comp);
FeatureKind k = (int)comp.size() >= cfg.geoContinentMinCells ? FeatureKind::Continent : FeatureKind::Island;
int fi = (int)geoFeatures.size();
int bank = namegen::bankForRegion(cfg.seed, fi);
addFeature(k, anchor, fi, (int)comp.size(), bank); // a land feature's regionId is itself
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;
}
// --- Lakes (inland filled basins above sea level) --------------------------------------------
if ((int)sLakeDepth.size() == n) {
auto lakes = components([&](int i) {
return cells[i].elevation > sea && sLakeDepth[i] > cfg.biomeLakeMinDepth;
});
std::sort(lakes.begin(), lakes.end(), bySizeDesc);
for (auto& comp : lakes) {
int anchor = centroidCell(comp);
int regId = sCellLand[anchor];
int fi = addFeature(FeatureKind::Lake, anchor, regId, (int)comp.size(), bankOf(regId, anchor));
for (int i : comp) sCellWater[i] = fi; // inland cells: lake overrides (was -1)
}
}
// --- Mountain ranges + their highest Peak ----------------------------------------------------
auto ranges = components([&](int i) {
return cells[i].elevation > sea && cells[i].elevation > cfg.geoMountainElev;
});
std::sort(ranges.begin(), ranges.end(), bySizeDesc);
std::vector<std::pair<int, double>> peaks; // (cell, elevation) high point of each range
for (auto& comp : ranges) {
if ((int)comp.size() < cfg.geoRangeMinCells) continue;
int hi = comp.front();
for (int i : comp) if (cells[i].elevation > cells[hi].elevation) hi = i;
int anchor = centroidCell(comp);
int regId = sCellLand[anchor];
int fi = addFeature(FeatureKind::MountainRange, anchor, regId, (int)comp.size(), bankOf(regId, anchor));
for (int i : comp) sCellRange[i] = fi;
peaks.push_back({ hi, cells[hi].elevation });
}
std::sort(peaks.begin(), peaks.end(), [](auto& a, auto& b) { return a.second > b.second; });
int peakCap = std::min((int)peaks.size(), std::max(0, cfg.geoMaxPeaks));
for (int pi = 0; pi < peakCap; ++pi) {
int hc = peaks[pi].first; int regId = sCellLand[hc];
addFeature(FeatureKind::Peak, hc, regId, 1, bankOf(regId, hc));
}
// --- Rivers: trace each major mouth upstream along the dominant tributary ---------------------
if ((int)sDischarge.size() == n && (int)sFlowTo.size() == n) {
std::vector<std::pair<int, double>> mouths; // (mouth cell, discharge)
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || sDischarge[i] < cfg.geoRiverMinDischarge) continue;
int d = sFlowTo[i];
if (d < 0 || cells[d].elevation <= sea) mouths.push_back({ i, sDischarge[i] }); // to sink or ocean
}
std::sort(mouths.begin(), mouths.end(), [](auto& a, auto& b) { return a.second > b.second; });
int rivCap = std::min((int)mouths.size(), std::max(0, cfg.geoMaxRivers));
std::vector<char> onStem(n, 0);
for (int ri = 0; ri < rivCap; ++ri) {
int mouth = mouths[ri].first;
std::vector<int> stem;
for (int cur = mouth; cur >= 0 && !onStem[cur]; ) {
onStem[cur] = 1; stem.push_back(cur);
int up = -1; double ub = -1.0; // upstream neighbour with the most discharge
for (int j : cells[cur].neighbors)
if (cells[j].elevation > sea && sFlowTo[j] == cur && sDischarge[j] > ub) { ub = sDischarge[j]; up = j; }
cur = up;
}
if ((int)stem.size() < 2) continue;
int anchor = stem[stem.size() / 2];
int regId = sCellLand[mouth];
int fi = addFeature(FeatureKind::River, anchor, regId, (int)stem.size(), bankOf(regId, anchor));
for (int i : stem) if (sCellRiver[i] < 0) sCellRiver[i] = fi;
}
}
}

View File

@ -0,0 +1,27 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
// Geography stage (the atlas) -- the foundation of the civilization arc. Geographic features are
// extracted from the (frozen) terrain by connectivity (flood-fill over the fixed grid) and given a
// procedurally generated name; everything later (settlements, territory, cultures) references them.
// Raylib-free + deterministic: see Planet::generateGeography() in PlanetGeography.cpp.
enum class FeatureKind : uint8_t {
Continent, Island, Ocean, Sea, Lake, MountainRange, Peak, River
};
// One named feature. `anchorCell` is the cell to label / focus on; `regionId` is the index (into
// Planet::geoFeatures) of the containing continent/island (-1 = none, e.g. open ocean); `size` is the
// member-cell count for areas or the stem length for rivers (used for label priority / font size).
struct GeoFeature {
uint32_t id = 0;
FeatureKind kind = FeatureKind::Continent;
int anchorCell = -1;
int regionId = -1;
int size = 0;
std::string name;
};
const char* featureKindName(FeatureKind k); // "Continent", "Mountains", "River", ... (PlanetGeography.cpp)

View File

@ -48,10 +48,12 @@
D(volcanoExplodeDropFrac) D(volcanoActivityDecay) D(volcanoDeadActivity) \ D(volcanoExplodeDropFrac) D(volcanoActivityDecay) D(volcanoDeadActivity) \
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \ D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \ D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
D(geoMountainElev) D(geoRiverMinDischarge) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \ I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed) U(seed)
@ -242,6 +244,8 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.volcanoAshPuffCellsPerWeek, 0.0, 1000.0, "volcanoAshPuffCellsPerWeek")); E(rng(cfg.volcanoAshPuffCellsPerWeek, 0.0, 1000.0, "volcanoAshPuffCellsPerWeek"));
E(rng(cfg.volcanoAshCloud, 0.0, 10.0, "volcanoAshCloud")); E(rng(cfg.volcanoAshCloud, 0.0, 10.0, "volcanoAshCloud"));
E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling")); 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(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -256,6 +260,11 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings")); E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings"));
E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax")); E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax"));
E(irng(cfg.volcanoMaxCount, 0, 100000, "volcanoMaxCount")); E(irng(cfg.volcanoMaxCount, 0, 100000, "volcanoMaxCount"));
E(irng(cfg.geoContinentMinCells, 1, 1000000, "geoContinentMinCells"));
E(irng(cfg.geoSeaMaxCells, 0, 1000000, "geoSeaMaxCells"));
E(irng(cfg.geoRangeMinCells, 1, 1000000, "geoRangeMinCells"));
E(irng(cfg.geoMaxRivers, 0, 100000, "geoMaxRivers"));
E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots")); E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots")); E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots")); E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
@ -367,14 +376,28 @@ void Planet::writeState(std::ostream& os) const {
// older readers stop before this block. // older readers stop before this block.
writeVec(os, volcanoes); writeVec(os, volcanoes);
writePod(os, sVolRng); writePod(os, sVolRng);
// v17: named geographic features (the atlas) + per-cell region indices. Feature records carry a
// std::string name, so they are written field-by-field (not POD); the index arrays are POD.
uint64_t nf = geoFeatures.size(); writePod(os, nf);
for (const GeoFeature& f : geoFeatures) {
writePod(os, f.id);
uint8_t k = (uint8_t)f.kind; writePod(os, k);
writePod(os, f.anchorCell); writePod(os, f.regionId); writePod(os, f.size);
uint64_t L = f.name.size(); writePod(os, L);
if (L) os.write(f.name.data(), (std::streamsize)L);
}
writeVec(os, sCellLand); writeVec(os, sCellWater);
writeVec(os, sCellRange); writeVec(os, sCellRiver);
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes) { bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography) {
// Save blocks are append-only by version. If a caller asks for an older prefix, // Save blocks are append-only by version. If a caller asks for an older prefix,
// later blocks cannot exist in that stream even if the default arguments say otherwise. // later blocks cannot exist in that stream even if the default arguments say otherwise.
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; }
if (!hasWeather) { hasStorms = false; hasVolcanoes = false; } // volcano block follows the weather block if (!hasWeather) { hasStorms = false; hasVolcanoes = false; } // volcano block follows the weather block
if (!hasVolcanoes || !hasStatefulVolcanoes) hasGeography = false; // geography block follows the volcano block
// Read the length-prefixed key=value config block (see writeState). A default // Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their // PlanetConfig is parsed over, so fields absent from an older save keep their
@ -502,6 +525,40 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
|| !std::isfinite(v.timer) || !std::isfinite(v.ashTimer) || !std::isfinite(v.timer) || !std::isfinite(v.ashTimer)
|| !std::isfinite(v.ashCarry) || v.phase > 1) return false; || !std::isfinite(v.ashCarry) || v.phase > 1) return false;
} }
// v17: named geographic features (the atlas). Older saves load with none (regenerate on demand).
geoFeatures.clear(); sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u;
sCellLand.assign(cells.size(), -1); sCellWater.assign(cells.size(), -1);
sCellRange.assign(cells.size(), -1); sCellRiver.assign(cells.size(), -1);
if (hasGeography) {
uint64_t nf = 0; readPod(is, nf);
if (!is || nf > 200000) return false;
geoFeatures.resize((size_t)nf);
for (GeoFeature& f : geoFeatures) {
readPod(is, f.id);
uint8_t k = 0; readPod(is, k);
if (k > (uint8_t)FeatureKind::River) return false;
f.kind = (FeatureKind)k;
readPod(is, f.anchorCell); readPod(is, f.regionId); readPod(is, f.size);
uint64_t L = 0; readPod(is, L);
if (!is || L > 256) return false;
f.name.resize((size_t)L);
if (L) is.read(&f.name[0], (std::streamsize)L);
if (!is || f.anchorCell < 0 || f.anchorCell >= (int)cells.size()) return false;
}
// Per-cell region index arrays. Accept an empty array (geography saved before generation)
// and treat it as "all -1"; otherwise it must be exactly cell-sized + reference valid features.
auto okArr = [&](std::vector<int>& a) {
if (a.empty()) { a.assign(cells.size(), -1); return true; }
if (a.size() != cells.size()) return false;
for (int v : a) if (v < -1 || v >= (int)geoFeatures.size()) return false;
return true;
};
if (!readVec(is, sCellLand, cells.size())) return false;
if (!readVec(is, sCellWater, cells.size())) return false;
if (!readVec(is, sCellRange, cells.size())) return false;
if (!readVec(is, sCellRiver, cells.size())) return false;
if (!okArr(sCellLand) || !okArr(sCellWater) || !okArr(sCellRange) || !okArr(sCellRiver)) return false;
}
computeBiotaDensity(); // derived density scalars for the colour views computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; return (bool)is;
} }

View File

@ -360,4 +360,16 @@ struct PlanetConfig {
double volcanoAshPuffCellsPerWeek = 2.0;// average local cells puffed per week while ashTimer runs double volcanoAshPuffCellsPerWeek = 2.0;// average local cells puffed per week while ashTimer runs
double volcanoAshCloud = 0.9; // cloud cover injected at the vent per erupting hour (ash plume) double volcanoAshCloud = 0.9; // cloud cover injected at the vent per erupting hour (ash plume)
double volcanoAshCooling = 6.0; // C: peak local cooling under an active ash plume double volcanoAshCooling = 6.0; // C: peak local cooling under an active ash plume
// --- Geography (the atlas) -- see PlanetGeography.cpp ----------------------
// Thresholds for extracting + naming geographic features from the frozen terrain. Generated once
// 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)
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
int geoMaxRivers = 40; // cap on named rivers (largest by discharge)
int geoMaxPeaks = 40; // cap on named peaks (highest)
}; };

163
test_geography.cpp Normal file
View File

@ -0,0 +1,163 @@
// 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;
}