Ecoregions atlas + Civilizations Step 2 (habitability & settlements, save v20)

Two features (the ecoregions layer was authored locally and was still
uncommitted; civilization Step 2 builds on top and is intermingled in shared
files, so they land together):

Ecoregions (v19, PlanetEcoregions.*, key E):
- generateEcoregions() flood-fills cells sharing biome + land/water context +
  productivity band into named ecological provinces (dominant flora/fauna/funga
  + per-kind productivity), a separate sEcoRng. ColorMode::Ecoregion + an Eco
  tab + cell-info dominants. Saved v19 (v18 geography reshuffle salt already in).

Civilizations Step 2 (v20, PlanetCiv.*, keys U/I):
- computeHabitability(): derived per-cell food/livability (climate comfort +
  water access (rivers/lakes/coast) + food (flora/fauna + ecoregion
  productivity), gated by freezing winters / high terrain). ColorMode::
  Habitability (key I).
- placeSettlements() (key U, "the dawn"): one-time greedy placement on the best
  well-spaced fertile cells (separate sCivRng; named from the continent's
  NameGen bank). The set is fixed, so the only mutable per-step state is each
  settlement's population.
- stepCivilization(): logistic growth toward K = civMaxPopulation*habitability,
  cut where an active volcano ashes the area, so settlements grow / decline /
  are abandoned (floored at 1 so a site can revive). Tiers village->town->city.
  Runs in liveAdvance; detectLiveEvents logs kind=3 events.
- Step-back snapshots only the population vector (WeatherSnapshot.settlementPop).
  3D + 2D tier-sized markers + city/town labels, a Civ tab, cell-info line.
  buildGeometry() clears settlements on reseed. Save v20; sCellSettlement
  rebuilt on load. civ* config knobs.

New test_ecoregions.cpp + test_civ.cpp; all 10 headless suites pass; GUI build
clean. CLAUDE.md / design-notes / BUILD.md updated (roadmap Step 2 done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-30 09:01:43 +02:00
parent 554878ef5e
commit 2351fb79a5
23 changed files with 1261 additions and 43 deletions

View File

@ -54,6 +54,9 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
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) M toggle place-name labels (names continents/oceans/ranges/rivers/lakes; Atlas tab)
E ecoregions colour view (names ecological provinces on first use; Eco tab)
I habitability heat map (where civilization can thrive)
U settlements: the dawn of civilization on first press, then toggle markers (Civ 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)
@ -277,6 +280,23 @@ once on a settled world, saved v17. The foundation of the civilization arc.
geoMaxRivers 40 cap on named rivers (largest by discharge) geoMaxRivers 40 cap on named rivers (largest by discharge)
geoMaxPeaks 40 cap on named peaks (highest) geoMaxPeaks 40 cap on named peaks (highest)
Civilization (PlanetConfig, key U): settlements placed once on the best fertile cells, then their
population grows/declines on the Live World clock toward a food-driven carrying capacity. Saved v20.
civMaxSettlements 80 cap on settlement sites
civMinSpacingRadians 0.10 min angular gap between sites (~640 km)
civMinHabitability 0.22 don't place a settlement below this habitability
civSeedPopulation 250 initial village population
civGrowthRate 0.02 logistic growth rate per year
civMaxPopulation 2e6 carrying capacity at habitability 1
civTownPop 5000 population at/above which a settlement is a Town
civCityPop 100000 population at/above which a settlement is a City
civAbandonPop 50 below this = abandoned/ruins (can revive)
civHabWaterWeight 0.45 habitability weight of water access (rivers/lakes/coast)
civHabFoodWeight 0.40 habitability weight of food (flora/fauna + ecoregion productivity)
civHabTempOpt 18 C most comfortable annual-mean temperature
civHabElevPenalty 2500 m high terrain steeply penalised above this
## 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 \
@ -286,12 +306,12 @@ once on a settled world, saved v17. The foundation of the civilization arc.
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/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp \
src/sim/PlanetCiv.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 / Geography suites: same source list, swap # Same source list for every suite: swap test_logic.cpp -> test_biota / test_live / test_ocean /
# test_logic.cpp -> test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp, # test_weather / test_volcano / test_geography / test_ecoregions / test_civ.
# 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

@ -108,7 +108,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
**Civilizations (in progress — the long arc after the world is finished):** the eventual goal is **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 + 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 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:** on the Live World clock). **Steps 12 of the roadmap are done (plus a derived ecoregions atlas):**
- **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the - **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the
foundation everything civic references. `Planet::generateGeography()` extracts named features from foundation everything civic references. `Planet::generateGeography()` extracts named features from
the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land), the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land),
@ -123,8 +123,25 @@ on the Live World clock). **Step 1 of the roadmap is done:**
features is this cell in" (the hook for territory/borders later). Names dedupe on the proper-noun 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 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*`. breaches (the island-formation event carries its name). Saved (**v17**). Knobs `geo*`.
*Next steps (not yet built): settlements + food/habitability, territory + borders, culture + - **Ecoregions (ecological provinces)** *(done — see `PlanetEcoregions.cpp`)*`generateEcoregions()`
beliefs, conflict + diplomacy.* flood-fills cells sharing a biome + land/water context + productivity band into named ecological
provinces (dominant flora/fauna/funga archetype + flora/fauna/funga productivity per region), a
separate `sEcoRng`. Key `E` (colour mode `Ecoregion` + an **Eco** tab); saved (**v19**; v18 added a
geography reshuffle salt). Productivity feeds settlement habitability.
- **Settlements & habitability (Step 2)** *(done — see `PlanetCiv.cpp`)* — a derived per-cell
**habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/
coast) + food (flora/fauna density + ecoregion productivity), gated by freezing winters / high
terrain; colour mode `Habitability`, key `I`). On key **`U`** ("the dawn") `placeSettlements()` seeds
a fixed set **once** on the best, well-spaced (`civMinSpacingRadians`) fertile cells (separate
`sCivRng`; named from the continent's `NameGen` bank). `stepCivilization()` runs each live frame:
each settlement's population moves **logistically toward a food-driven carrying capacity** `K =
civMaxPopulation·habitability` (cut transiently where an active volcano ashes the area), so it
**grows / declines / is abandoned** (kept in the set, can revive). Tiers village→town→city by
population; markers (3D + 2D) sized by tier + a **Civ** tab + cell-info line + kind=3 `WorldEvent`s
("X grew into a city", "X was abandoned"). Since the set is fixed, the step-back snapshot only
restores the per-settlement **population** vector. Saved (**v20**). Knobs `civ*`.
*Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict +
diplomacy.*
## Current state ## Current state
@ -532,6 +549,8 @@ src/
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) NameGen.* deterministic procedural name generator (syllable banks; reused by civ arc)
PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes) PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes)
PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2)
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)
@ -594,12 +613,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/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.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`, `test_volcano.cpp` or `test_geography.cpp` to run the Biota / Live World / Ocean / `test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp` or `test_civ.cpp`
Weather / Volcano / Geography suites — same source list. CMake also builds `test_events` for the to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / Civilization
suites — same source list. CMake also builds `test_events` for the
viewer event journal.) 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
@ -633,6 +653,8 @@ active mode shown top-center of the globe) ·
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) · `M` place-name labels (the atlas; names the world on first use) · `V` volcano markers (Live World) · `M` place-name labels (the atlas; names the world on first use) ·
`E` ecoregions colour view (names ecology on first use) · `I` habitability heat map ·
`U` settlements (the dawn of civilization on first press; toggles markers after) ·
`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
@ -672,7 +694,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 **17**; v2 adds the `[`/`]` drift rate, v3 a save header is versioned (currently **20**; 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**
@ -681,8 +703,10 @@ 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, v16 appends the saved **event journal**, and v17 agents plus volcano state in step-back frames, v16 appends the saved **event journal**, v17
appends the **geography/atlas** block — named features + per-cell region indices; appends the **geography/atlas** block — named features + per-cell region indices, v18 a geography
reshuffle salt, v19 the **ecoregions** block, and v20 the **civilization settlements** block (the
fixed settlement set + per-frame populations in the step-back history);
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
@ -691,7 +715,8 @@ load with no step-back history (you can still step forward then back); pre-v13 s
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-v17 saves load with no geography (regenerated on pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
demand via `M`). demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with
no settlements (re-seeded via `U`).
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
@ -826,6 +851,16 @@ triangles (plates are fixed in phase 1).
a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints 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 a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
constants in NameGen.cpp / ViewerRender.cpp, not config. constants in NameGen.cpp / ViewerRender.cpp, not config.
- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement — `civMaxSettlements`
(80, cap), `civMinSpacingRadians` (0.10 rad ≈ 640 km, min gap between sites), `civMinHabitability`
(0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight`
(0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty`
(2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial
village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at
habitability 1), tier thresholds `civTownPop` (5000) / `civCityPop` (100000), `civAbandonPop` (50,
below = abandoned/ruins but can revive). An active volcano's ash within ~1.5× its blast radius cuts a
settlement's carrying capacity (the "ashed-out region" decline). Marker sizes/colours are render
constants (ViewerRender.cpp).
- `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

@ -34,6 +34,8 @@ set(SIM_SOURCES
src/sim/PlanetFungiGen.cpp src/sim/PlanetFungiGen.cpp
src/sim/NameGen.cpp src/sim/NameGen.cpp
src/sim/PlanetGeography.cpp src/sim/PlanetGeography.cpp
src/sim/PlanetEcoregions.cpp
src/sim/PlanetCiv.cpp
src/sim/PlanetIO.cpp src/sim/PlanetIO.cpp
) )
@ -72,7 +74,7 @@ if(UNIX AND NOT APPLE)
endif() endif()
enable_testing() enable_testing()
foreach(test_name logic biota ocean live weather volcano geography) foreach(test_name logic biota ocean live weather volcano geography ecoregions civ)
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

@ -47,7 +47,11 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
islands; saved v15). islands; saved v15).
- `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc). - `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc).
- `PlanetGeography.{hpp,cpp}``generateGeography()` (named features: continents/oceans/ranges/ - `PlanetGeography.{hpp,cpp}``generateGeography()` (named features: continents/oceans/ranges/
rivers/lakes; the atlas, saved v17). rivers/lakes; the atlas, saved v17+).
- `PlanetEcoregions.{hpp,cpp}``generateEcoregions()` (named ecological provinces from biome,
land/water context, productivity and broad biota; saved v19).
- `PlanetCiv.{hpp,cpp}``computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2:
habitability + settlements that grow/decline on the live clock; saved v20).
- `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),
@ -321,16 +325,58 @@ magic`) + a per-feature hash, so it is deterministic and **never perturbs the te
size) plus four per-cell index arrays (`sCellLand`/`sCellWater`/`sCellRange`/`sCellRiver`) giving O(1) 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 "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 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 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 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`), 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, 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. written field-by-field) + the POD index arrays; pre-v17 saves load with none and regenerate on demand.
Save v18 appends the active geography reshuffle salt (`Shift+M`) so repeated renames continue after
load.
Names dedupe on the **proper-noun root** (not the formatted string), so a continent, its river and its 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 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 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. landmass or mints a fresh unique Island name, which the island-formation `WorldEvent` then carries.
## Ecoregions — named ecological provinces
`PlanetEcoregions.cpp` (engine, raylib-free, deterministic) adds the next atlas-like layer after
geography. `Planet::generateEcoregions()` ensures climate/biomes, biota density, hydrology and
geography exist, then flood-fills connected cells by **biome + land/ocean/wet context + similar
productivity band**. Tiny fragments merge into an adjacent compatible region when possible. Each
`Ecoregion` stores a name, biome, anchor cell, containing geography feature id, size, average
flora/fauna/funga productivity and dominant broad flora/fauna/funga archetype. If the discrete biota
population exists (`L`), dominants come from the actual placed organisms; otherwise they are inferred
from density + archetype suitability. Ecoregions summarize existing broad ecology — they do not create
new species or a food-web simulation.
Names use `NameGen` with the containing geography bank, so ecological names inherit regional sound
without touching the tectonic RNG. Viewer key `E` lazily generates/toggles the ecoregion colour view,
cell-info shows the local ecoregion and dominants, and Live World has an **Eco** tab (6th tab; click
a row → `focusCell`). Save **v19** appends the ecoregion records and `sCellEcoregion`; pre-v19 saves
load with none and regenerate on demand.
## Civilization Step 2 — habitability & settlements
`PlanetCiv.cpp` (engine, raylib-free, deterministic, separate `sCivRng`). `computeHabitability()` is a
derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access
(river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion
productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn")
seeds a **fixed** set once — greedily the highest-habitability cells with a minimum angular spacing
(the ocean-basin farthest-first idiom) — naming each from its continent's `NameGen` bank.
Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state
is each settlement's **population** — which is all the step-back snapshot stores (a `vector<double>` in
`WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours)`
runs in `liveAdvance` after `stepVolcanoes`: each population moves logistically toward a carrying
capacity `K = civMaxPopulation·habitability(cell)`, cut transiently where an active volcano's ash plume
(`ashTimer>0`, within ~1.5× blast radius) overlaps — so towns **grow, decline, and are abandoned**
(floored at 1 so a site can revive when K recovers). Tier (village/town/city) is derived from
population. The viewer compares before/after populations in `detectLiveEvents` to log kind=3
`WorldEvent`s (tier crossings, abandonment), draws markers (3D spheres + 2D dots, sized by tier; city/
town names as 3D labels), adds a **Civ** tab (7th) and a cell-info line, and a `Habitability` colour
mode (key `I`). `buildGeometry()` clears the set on reseed (like geography/volcanoes). Save **v20**
appends the settlement records (population included); `sCellSettlement` is rebuilt on load. Knobs `civ*`.
## 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:

View File

@ -91,6 +91,8 @@ const char* colorModeName(ColorMode m) {
case ColorMode::FloraDensity: return "Flora density"; case ColorMode::FloraDensity: return "Flora density";
case ColorMode::FaunaDensity: return "Fauna density"; case ColorMode::FaunaDensity: return "Fauna density";
case ColorMode::FungaDensity: return "Funga density"; case ColorMode::FungaDensity: return "Funga density";
case ColorMode::Ecoregion: return "Ecoregions";
case ColorMode::Habitability: return "Habitability";
case ColorMode::TempSummer: return "Temperature (summer)"; case ColorMode::TempSummer: return "Temperature (summer)";
case ColorMode::TempWinter: return "Temperature (winter)"; case ColorMode::TempWinter: return "Temperature (winter)";
case ColorMode::Seasonality: return "Seasonality (summer-winter)"; case ColorMode::Seasonality: return "Seasonality (summer-winter)";
@ -140,6 +142,36 @@ Color marineFaunaColor(double d01) { // deep blue -> cyan -> warm (rich shelve
return Color{ L(0), L(1), L(2), 255 }; return Color{ L(0), L(1), L(2), 255 };
} }
Color habitabilityColor(double h01) { // barren grey -> green -> fertile gold
double t = std::clamp(h01, 0.0, 1.0);
static const unsigned char key[3][3] = {
{ 70, 74, 82 }, // 0.0 barren grey
{ 70, 150, 80 }, // 0.5 green
{ 230, 205, 90 }, // 1.0 fertile gold
};
double s = t * 2.0; int k = std::min(1, (int)s); double f = s - k;
auto L = [&](int c) { return (unsigned char)(key[k][c] + (key[k + 1][c] - key[k][c]) * f); };
return Color{ L(0), L(1), L(2), 255 };
}
Color ecoregionColor(int id, Biome b, double productivity) {
if (id < 0) return Color{ 48, 52, 58, 255 };
Color base = ColorFromHSV(std::fmod((id + 11) * 0.61803398875f, 1.0f) * 360.0f, 0.55f, 0.82f);
Color bio = biomeColor(b);
double p = std::clamp(productivity, 0.0, 1.0);
auto mix = [&](unsigned char a, unsigned char c, double t) {
return (unsigned char)(a * (1.0 - t) + c * t);
};
double biomeWeight = 0.35;
Color out{ mix(base.r, bio.r, biomeWeight), mix(base.g, bio.g, biomeWeight),
mix(base.b, bio.b, biomeWeight), 255 };
double brighten = 0.72 + 0.28 * p;
out.r = (unsigned char)std::clamp(out.r * brighten, 0.0, 255.0);
out.g = (unsigned char)std::clamp(out.g * brighten, 0.0, 255.0);
out.b = (unsigned char)std::clamp(out.b * brighten, 0.0, 255.0);
return out;
}
// Temperature ramp over ~[-40, 40] C: deep blue -> cyan -> green -> yellow -> red. // Temperature ramp over ~[-40, 40] C: deep blue -> cyan -> green -> yellow -> red.
Color tempColor(double celsius) { Color tempColor(double celsius) {
double t = std::clamp((celsius + 40.0) / 80.0, 0.0, 1.0); // 0 cold .. 1 hot double t = std::clamp((celsius + 40.0) / 80.0, 0.0, 1.0); // 0 cold .. 1 hot

View File

@ -6,6 +6,7 @@
enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip, enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip,
FloraDensity, FaunaDensity, FungaDensity, FloraDensity, FaunaDensity, FungaDensity,
Ecoregion, Habitability,
TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views
Color elevationColor(double e, double seaLevel); Color elevationColor(double e, double seaLevel);
@ -38,3 +39,6 @@ Color fungaColor(double d01);
// blue->cyan->warm. Used for water cells in the flora/fauna views. // blue->cyan->warm. Used for water cells in the flora/fauna views.
Color marineFloraColor(double d01); Color marineFloraColor(double d01);
Color marineFaunaColor(double d01); Color marineFaunaColor(double d01);
Color ecoregionColor(int id, Biome b, double productivity);
// Habitability heat map (0..1): barren grey -> fertile green/gold (where civilization can thrive).
Color habitabilityColor(double h01);

View File

@ -61,6 +61,42 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
const char* lk = (c.elevation > p.cfg.seaLevel) ? nameOf(p.cellWater()) : nullptr; const char* lk = (c.elevation > p.cfg.seaLevel) ? nameOf(p.cellWater()) : nullptr;
if (lk) L.push_back(std::string(" ") + lk); if (lk) L.push_back(std::string(" ") + lk);
} }
if (p.ecoregionsBuilt()) {
const auto& E = p.ecoregions();
const auto& ce = p.cellEcoregion();
int ei = (i < (int)ce.size()) ? ce[i] : -1;
if (ei >= 0 && ei < (int)E.size()) {
const Ecoregion& e = E[ei];
L.push_back(std::string("ecoregion: ") + e.name);
auto dom = [&](const char* tag, int arch) {
if (arch < 0 || arch >= (int)biotaArchetypes().size()) return;
Organism o{ (uint16_t)arch, (uint8_t)e.biome };
const BiotaArchetype& a = biotaArchetypes()[arch];
L.push_back(std::string(" ") + tag + ": " + organismName(o) +
" (" + roleName(a.role) + ")");
};
dom("flora", e.dominantFlora);
dom("fauna", e.dominantFauna);
dom("funga", e.dominantFunga);
}
}
// Civilization: the cell's settlement (if any) + its habitability/food score.
if (sized(p.habitability()))
L.push_back(std::string(TextFormat("habitability %.0f%%", p.habitability()[i] * 100.0)));
if (p.settlementsPlaced()) {
const auto& cs = p.cellSettlement();
int si = (i < (int)cs.size()) ? cs[i] : -1;
if (si >= 0 && si < (int)p.settlements.size()) {
const Settlement& s = p.settlements[si];
SettleTier t = settleTierOf(s.population, p.cfg.civTownPop, p.cfg.civCityPop);
bool alive = s.population >= p.cfg.civAbandonPop;
const char* pop = s.population >= 1.0e6 ? TextFormat("%.2fM", s.population / 1.0e6)
: s.population >= 1.0e3 ? TextFormat("%.0fk", s.population / 1.0e3)
: TextFormat("%.0f", s.population);
L.push_back(std::string(alive ? settleTierName(t) : "Ruins of") + " " + s.name
+ " (pop " + pop + ")");
}
}
// 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

@ -119,6 +119,9 @@ void Viewer::recolor() {
const std::vector<double>& flora = planet.floraDensity(); const std::vector<double>& flora = planet.floraDensity();
const std::vector<double>& fauna = planet.faunaDensity(); const std::vector<double>& fauna = planet.faunaDensity();
const std::vector<double>& funga = planet.fungaDensity(); const std::vector<double>& funga = planet.fungaDensity();
const std::vector<int>& ecoCell = planet.cellEcoregion();
const auto& eco = planet.ecoregions();
const std::vector<double>& hab = planet.habitability();
vcolors.resize(planet.cells.size()); vcolors.resize(planet.cells.size());
for (size_t i = 0; i < planet.cells.size(); ++i) { for (size_t i = 0; i < planet.cells.size(); ++i) {
switch (mode) { switch (mode) {
@ -143,6 +146,20 @@ void Viewer::recolor() {
case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255} case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255}
: (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFaunaColor(fauna[i]) : faunaColor(fauna[i])); break; : (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFaunaColor(fauna[i]) : faunaColor(fauna[i])); break;
case ColorMode::FungaDensity: vcolors[i] = funga.empty() ? Color{90,90,90,255} : fungaColor(funga[i]); break; case ColorMode::FungaDensity: vcolors[i] = funga.empty() ? Color{90,90,90,255} : fungaColor(funga[i]); break;
case ColorMode::Ecoregion: {
int ei = (i < ecoCell.size()) ? ecoCell[i] : -1;
double prod = (ei >= 0 && ei < (int)eco.size())
? std::max({ eco[ei].floraProductivity, eco[ei].faunaProductivity, eco[ei].fungaProductivity })
: 0.0;
vcolors[i] = (ei >= 0 && ei < (int)eco.size())
? ecoregionColor(ei, eco[ei].biome, prod) : Color{55, 58, 64, 255};
break;
}
case ColorMode::Habitability:
vcolors[i] = (i < hab.size() && planet.cells[i].elevation > planet.cfg.seaLevel
&& planet.cells[i].biome != Biome::Ice)
? habitabilityColor(hab[i]) : Color{30, 42, 64, 255}; // ocean/ice: dim blue
break;
default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel); default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel);
} }
} }
@ -281,7 +298,8 @@ void Viewer::appendEvent(uint8_t kind, uint8_t severity, double timeHours, int c
} }
void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms, void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes) { const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements) {
auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* { auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* {
for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws; for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws;
return nullptr; return nullptr;
@ -332,6 +350,29 @@ void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
std::string(TextFormat("%s exploded, +%.0f m remains", kind, v.built))); std::string(TextFormat("%s exploded, +%.0f m remains", kind, v.built)));
} }
} }
// Civilization (kind=3): a settlement crossing a tier boundary or being abandoned / revived. The
// set is fixed, so compare by index against the before-snapshot.
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
auto popLine = [&](const Settlement& st) -> std::string {
double p = st.population;
if (p >= 1.0e6) return std::string(TextFormat("pop %.1fM", p / 1.0e6));
if (p >= 1.0e3) return std::string(TextFormat("pop %.0fk", p / 1.0e3));
return std::string(TextFormat("pop %.0f", p));
};
for (size_t k = 0; k < planet.settlements.size() && k < beforeSettlements.size(); ++k) {
const Settlement& s = planet.settlements[k];
const Settlement& o = beforeSettlements[k];
SettleTier tb = settleTierOf(o.population, townP, cityP), ta = settleTierOf(s.population, townP, cityP);
bool aliveB = o.population >= abP, aliveA = s.population >= abP;
if (aliveB && !aliveA)
appendEvent(3, 2, liveTime, s.cell, s.id, s.name + " was abandoned", popLine(s));
else if (!aliveB && aliveA)
appendEvent(3, 1, liveTime, s.cell, s.id, s.name + " was resettled", popLine(s));
else if (aliveA && (int)ta > (int)tb)
appendEvent(3, 1, liveTime, s.cell, s.id,
s.name + " grew into a " + settleTierName(ta), popLine(s));
}
} }
void Viewer::focusCell(int idx, const std::string& status) { void Viewer::focusCell(int idx, const std::string& status) {
@ -396,6 +437,7 @@ void Viewer::saveGame(const char* path) {
os.write((char*)&f.w.rng, 4); os.write((char*)&f.w.nextId, 4); os.write((char*)&f.w.rng, 4); os.write((char*)&f.w.nextId, 4);
wV(f.w.volcanoes); wV(f.w.volcanoes);
os.write((char*)&f.w.volRng, 4); os.write((char*)&f.w.volRng, 4);
wD(f.w.settlementPop); // v20: per-frame settlement populations
} }
// v16: persistent world event journal, separate from step-back history. // v16: persistent world event journal, separate from step-back history.
uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX); uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX);
@ -429,7 +471,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, ver >= 17, ver >= 18)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements
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
@ -462,6 +504,13 @@ void Viewer::loadGame(const char* path) {
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(Volcano))); if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(Volcano)));
if (!is) historyOk = false; if (!is) historyOk = false;
}; };
auto rP = [&](std::vector<double>& v){ // settlement populations (not cell-sized)
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
uint32_t hn = 0; is.read((char*)&hn, 4); uint32_t hn = 0; is.read((char*)&hn, 4);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false; if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
for (uint32_t k = 0; k < hn && is; ++k) { for (uint32_t k = 0; k < hn && is; ++k) {
@ -474,6 +523,7 @@ void Viewer::loadGame(const char* path) {
is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4); is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4);
rV(f.w.volcanoes); rV(f.w.volcanoes);
is.read((char*)&f.w.volRng, 4); is.read((char*)&f.w.volRng, 4);
if (ver >= 20) rP(f.w.settlementPop); // v20: per-frame settlement populations
auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); }; auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain) if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size()) || f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
@ -601,17 +651,20 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
} }
std::vector<WeatherSystem> beforeStorms; std::vector<WeatherSystem> beforeStorms;
std::vector<Volcano> beforeVolcanoes; std::vector<Volcano> beforeVolcanoes;
std::vector<Settlement> beforeSettlements;
if (dtWeather > 0.0) { if (dtWeather > 0.0) {
beforeStorms = planet.storms(); beforeStorms = planet.storms();
beforeVolcanoes = planet.volcanoes; beforeVolcanoes = planet.volcanoes;
beforeSettlements = planet.settlements;
} }
planet.stepWeather(dtWeather); planet.stepWeather(dtWeather);
// Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here // Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// reasserts restored terrain/biome state without advancing the lifecycle. // reasserts restored terrain/biome state without advancing the lifecycle.
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather); VolcanoUpdate vu = planet.stepVolcanoes(dtWeather);
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes); CivUpdate cu = planet.stepCivilization(dtWeather); // population grows/declines on the clock
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements);
if (vu.breach) refreshView(); if (vu.breach) refreshView();
else if (vu.recolor) recolor(); else if (vu.recolor || cu.recolor) recolor();
rebuildLiveOverlay(); rebuildLiveOverlay();
} }

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 = 18; // v18: geography reshuffle salt; 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 uint32_t SAVE_VERSION = 20; // v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; 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";
@ -100,7 +100,8 @@ struct Viewer {
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) 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) bool showSettlements = true; // civilization settlement markers (key U seeds + toggles)
std::vector<int> atlasRowCells; // cell to focus per visible Atlas/Eco/Civ-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 {
@ -114,7 +115,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, 4 Atlas int liveInfoTab = 0; // 0 Sky, 1 Tides, 2 Weather, 3 Events, 4 Atlas, 5 Eco
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
@ -172,7 +173,8 @@ struct Viewer {
void appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId, void appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId,
const std::string& title, const std::string& detail); const std::string& title, const std::string& detail);
void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms, void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes); const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements);
void focusCell(int idx, const std::string& status = ""); void focusCell(int idx, const std::string& status = "");
// ---- Input (ViewerInput.cpp) -------------------------------------------- // ---- Input (ViewerInput.cpp) --------------------------------------------

View File

@ -44,7 +44,7 @@ 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 } else if (liveInfoTab >= 4) { // Atlas/Eco/Civ: click a row to fly there
for (size_t i = 0; i < eventRowRects.size() && i < atlasRowCells.size(); ++i) { for (size_t i = 0; i < eventRowRects.size() && i < atlasRowCells.size(); ++i) {
if (!CheckCollisionPointRec(mp, eventRowRects[i])) continue; if (!CheckCollisionPointRec(mp, eventRowRects[i])) continue;
if (atlasRowCells[i] >= 0) focusCell(atlasRowCells[i], ""); if (atlasRowCells[i] >= 0) focusCell(atlasRowCells[i], "");
@ -185,10 +185,36 @@ void Viewer::handleInput() {
} }
if (IsKeyPressed(KEY_L) && settled) { // generate / regenerate biota population if (IsKeyPressed(KEY_L) && settled) { // generate / regenerate biota population
planet.generateBiota(); planet.generateBiota();
if (planet.ecoregionsBuilt()) planet.generateEcoregions();
if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity) if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity)
{ mode = ColorMode::FloraDensity; recolor(); } { mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)"); setStatus("Biota generated (flora/fauna/funga)");
} }
if (IsKeyPressed(KEY_E) && settled) { // generate / toggle ecoregion atlas colour view
if (!planet.ecoregionsBuilt()) planet.generateEcoregions();
mode = (mode == ColorMode::Ecoregion) ? ColorMode::Biome : ColorMode::Ecoregion;
recolor();
setStatus(mode == ColorMode::Ecoregion ? "Ecoregions on" : "Ecoregions off");
}
if (IsKeyPressed(KEY_I) && settled) { // toggle the habitability heat-map view
planet.computeHabitability();
mode = (mode == ColorMode::Habitability) ? ColorMode::Biome : ColorMode::Habitability;
recolor();
setStatus(mode == ColorMode::Habitability ? "Habitability on" : "Habitability off");
}
if (IsKeyPressed(KEY_U) && settled) { // civilization: seed on first press ("the dawn"), then toggle markers
if (!planet.settlementsPlaced()) {
planet.placeSettlements();
showSettlements = true;
appendEvent(3, 1, liveTime, planet.settlements.empty() ? 0 : planet.settlements[0].cell, 0,
"Civilization begins",
std::string(TextFormat("%d villages founded", (int)planet.settlements.size())));
setStatus(TextFormat("Civilization begins (%d settlements)", (int)planet.settlements.size()));
} else {
showSettlements = !showSettlements;
setStatus(showSettlements ? "Settlements on" : "Settlements off");
}
}
if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock) if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock)
liveWorld = !liveWorld; liveWorld = !liveWorld;
if (liveWorld) { if (liveWorld) {

View File

@ -208,6 +208,29 @@ void Viewer::renderGlobe3D() {
} }
} }
} }
// Settlement markers (civilization): a dot per settlement, sized + coloured by tier; dim for ruins.
if (showSettlements && !planet.settlements.empty()) {
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
for (const Settlement& s : planet.settlements) {
if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue;
const Cell& c = planet.cells[s.cell];
float r = visBase + (float)c.elevation * elevExagg + 0.006f;
Vector3 p{ (float)(c.unit.x * r), (float)(c.unit.y * r), (float)(c.unit.z * r) };
bool alive = s.population >= abP;
SettleTier t = settleTierOf(s.population, townP, cityP);
float rad = t == SettleTier::City ? 0.026f : t == SettleTier::Town ? 0.018f : 0.012f;
Color col = !alive ? Color{110, 110, 116, 255}
: t == SettleTier::City ? Color{250, 220, 110, 255}
: t == SettleTier::Town ? Color{225, 170, 90, 255}
: Color{210, 130, 85, 255};
DrawSphere(p, rad, col);
if (alive && t != SettleTier::Village) { // a ring marks notable settlements
float rr = visBase + (float)c.elevation * elevExagg + 0.01f;
Vector3 e = { (float)(c.unit.x * rr), (float)(c.unit.y * rr), (float)(c.unit.z * rr) };
DrawSphereWires(e, rad + 0.008f, 6, 6, Color{255, 245, 210, 150});
}
}
}
if (showGrat) drawGraticule3D(graticule, gratR); if (showGrat) drawGraticule3D(graticule, gratR);
// Markers: selected (orange), hovered cell (yellow), hovered subcell (white). // Markers: selected (orange), hovered cell (yellow), hovered subcell (white).
if (selectedCell >= 0) { if (selectedCell >= 0) {
@ -330,6 +353,24 @@ void Viewer::renderMap2D() {
Color{255, 170, 70, (unsigned char)std::clamp(90.0 + 150.0 * er, 0.0, 255.0)}); Color{255, 170, 70, (unsigned char)std::clamp(90.0 + 150.0 * er, 0.0, 255.0)});
} }
} }
if (showSettlements && !planet.settlements.empty()) {
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
float zf = (float)std::min(2.0, mapZoom);
for (const Settlement& s : planet.settlements) {
if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue;
double lon, lat; dirToLonLat(planet.cells[s.cell].unit, lon, lat);
Vector2 sp = projLonLat(lon, lat, mapLon, vr);
bool alive = s.population >= abP;
SettleTier t = settleTierOf(s.population, townP, cityP);
float rad = (t == SettleTier::City ? 4.5f : t == SettleTier::Town ? 3.2f : 2.2f) * zf;
Color col = !alive ? Color{120, 120, 126, 255}
: t == SettleTier::City ? Color{250, 220, 110, 255}
: t == SettleTier::Town ? Color{225, 170, 90, 255}
: Color{210, 130, 85, 255};
DrawCircleV(sp, rad, col);
if (alive && t != SettleTier::Village) DrawCircleLines((int)sp.x, (int)sp.y, rad + 2.0f, Color{255, 245, 210, 180});
}
}
if (phase3 && showRivers) { if (phase3 && showRivers) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, vr, mapLon); drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, vr, mapLon);
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, vr, mapLon); drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, vr, mapLon);
@ -376,17 +417,18 @@ void Viewer::renderLiveInfo() {
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[5] = { "Sky", "Tides", "Weather", "Events", "Atlas" }; const char* tabs[7] = { "Sky", "Tides", "Weather", "Events", "Atlas", "Eco", "Civ" };
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 < 5; ++i) { for (int i = 0; i < 7; ++i) {
float tw = (r.width - 20.0f) / 5.0f; float tw = (r.width - 20.0f) / 7.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;
DrawRectangleRec(tr, on ? Color{42, 48, 68, 255} : Color{18, 22, 34, 255}); DrawRectangleRec(tr, on ? Color{42, 48, 68, 255} : Color{18, 22, 34, 255});
DrawRectangleLinesEx(tr, 1, on ? Color{125, 145, 190, 255} : Color{65, 70, 90, 255}); DrawRectangleLinesEx(tr, 1, on ? Color{125, 145, 190, 255} : Color{65, 70, 90, 255});
int w = MeasureText(tabs[i], 14); int tfs = 12; // smaller font: 7 tabs are narrow
DrawText(tabs[i], (int)(tr.x + (tr.width - w) * 0.5f), (int)tr.y + 5, 14, int w = MeasureText(tabs[i], tfs);
DrawText(tabs[i], (int)(tr.x + (tr.width - w) * 0.5f), (int)tr.y + 6, tfs,
on ? RAYWHITE : Color{155, 165, 185, 255}); on ? RAYWHITE : Color{155, 165, 185, 255});
} }
y = (int)r.y + 72; y = (int)r.y + 72;
@ -507,7 +549,7 @@ void Viewer::renderLiveInfo() {
y += 43; y += 43;
} }
} }
} else { // Atlas: named geographic features, grouped by kind; click a row to fly there } else if (liveInfoTab == 4) { // Atlas: named geographic features, grouped by kind; click a row to fly there
const auto& F = planet.geography(); const auto& F = planet.geography();
DrawText("Atlas", x, y, 18, Color{200, 205, 220, 255}); 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}); DrawText(TextFormat("%d named", (int)F.size()), (int)(r.x + r.width) - 78, y + 2, 13, Color{145, 155, 175, 255});
@ -544,6 +586,68 @@ void Viewer::renderLiveInfo() {
y += 4; y += 4;
} }
} }
} else if (liveInfoTab == 5) { // Eco: named ecoregions, richest first; click a row to fly there
const auto& E = planet.ecoregions();
DrawText("Ecoregions", x, y, 18, Color{200, 205, 220, 255});
DrawText(TextFormat("%d named", (int)E.size()), (int)(r.x + r.width) - 78, y + 2, 13, Color{145, 155, 175, 255});
y += 26;
if (E.empty()) {
DrawText(planet.ecoregionsBuilt() ? "(none)" : "press E to name ecology", x, y, 14, Color{150, 155, 170, 255});
} else {
std::vector<int> idx(E.size());
for (size_t i = 0; i < E.size(); ++i) idx[i] = (int)i;
std::sort(idx.begin(), idx.end(), [&](int a, int b) {
double pa = std::max({ E[a].floraProductivity, E[a].faunaProductivity, E[a].fungaProductivity });
double pb = std::max({ E[b].floraProductivity, E[b].faunaProductivity, E[b].fungaProductivity });
if (std::fabs(pa - pb) > 1e-9) return pa > pb;
return E[a].size > E[b].size;
});
for (int ei : idx) {
if (y > (int)(r.y + r.height) - 34) break;
const Ecoregion& e = E[ei];
Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 32.0f };
eventRowRects.push_back(row); atlasRowCells.push_back(e.anchorCell);
double prod = std::max({ e.floraProductivity, e.faunaProductivity, e.fungaProductivity });
DrawRectangleRec(row, Color{18, 22, 34, 205});
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255});
DrawText(e.name.c_str(), (int)row.x + 8, (int)row.y + 2, 14, ecoregionColor(ei, e.biome, prod));
DrawText(TextFormat("%s %.0f%% life %d cells", biomeName(e.biome), prod * 100.0, e.size),
(int)row.x + 8, (int)row.y + 18, 11, Color{150, 158, 178, 255});
y += 35;
}
}
} else { // Civ: settlements by population (largest first); click a row to fly there
const auto& S = planet.settlements;
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
DrawText("Settlements", x, y, 18, Color{200, 205, 220, 255});
DrawText(TextFormat("%d", (int)S.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255});
y += 26;
if (S.empty()) {
DrawText(planet.settlementsPlaced() ? "(none)" : "press U for the dawn of civilization", x, y, 13, Color{150, 155, 170, 255});
} else {
std::vector<int> idx(S.size());
for (size_t i = 0; i < S.size(); ++i) idx[i] = (int)i;
std::sort(idx.begin(), idx.end(), [&](int a, int b) { return S[a].population > S[b].population; });
for (int si : idx) {
if (y > (int)(r.y + r.height) - 22) break;
const Settlement& s = S[si];
bool alive = s.population >= abP;
SettleTier t = settleTierOf(s.population, townP, cityP);
Color fg = !alive ? Color{120, 120, 128, 255}
: t == SettleTier::City ? Color{245, 215, 110, 255}
: t == SettleTier::Town ? Color{210, 200, 150, 255}
: Color{185, 195, 175, 255};
Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f };
eventRowRects.push_back(row); atlasRowCells.push_back(s.cell);
const char* pop = s.population >= 1.0e6 ? TextFormat("%.1fM", s.population / 1.0e6)
: s.population >= 1.0e3 ? TextFormat("%.0fk", s.population / 1.0e3)
: TextFormat("%.0f", s.population);
DrawText(s.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, fg);
const char* tag = !alive ? "ruins" : settleTierName(t);
DrawText(TextFormat("%s %s", tag, pop), (int)(r.x + r.width) - 92, (int)row.y + 3, 11, Color{150, 158, 178, 255});
y += 20;
}
}
} }
} }
@ -619,11 +723,12 @@ 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 E eco (*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]", line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off")); showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off"));
line(TextFormat("K clouds [%s] | V volcanoes [%s] | M names [%s] | Shift+M rename/recheck atlas", line(TextFormat("K clouds [%s] | V volcanoes [%s] | M names [%s] | E eco | I habitability | U settlements [%s]",
showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off")); showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off",
!planet.settlementsPlaced() ? "seed" : showSettlements ? "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");
@ -727,6 +832,37 @@ void Viewer::renderFrame() {
} }
} }
// 3D settlement labels: name towns + cities (villages only when zoomed in), same manual projection.
if (showSettlements && !planet.settlements.empty()) {
Vec3 camPos{cam.position.x, cam.position.y, cam.position.z};
Vec3 camTgt{cam.target.x, cam.target.y, cam.target.z};
Vec3 forward = (camTgt - camPos).normalized();
Vec3 right = forward.cross(Vec3{cam.up.x, cam.up.y, cam.up.z}).normalized();
Vec3 up = right.cross(forward);
double fovRad = cam.fovy * M_PI / 180.0, aspect = (double)view3DW / view3DH;
double projH = std::tan(fovRad * 0.5), projW = projH * aspect;
bool zoomed = camDist < 5.0;
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
for (const Settlement& s : planet.settlements) {
if (s.cell < 0 || s.cell >= (int)planet.cells.size() || s.population < abP) continue;
SettleTier t = settleTierOf(s.population, townP, cityP);
if (t == SettleTier::Village && !zoomed) continue; // declutter
int font = t == SettleTier::City ? 15 : t == SettleTier::Town ? 13 : 12;
const Cell& c = planet.cells[s.cell];
double sr = visBase + (double)c.elevation * elevExagg + 0.02;
Vec3 lp = rotateZ(c.unit, planet.cfg.axialTilt) * sr;
if (lp.dot(camPos) <= 0.0) continue;
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(s.name.c_str(), font);
DrawText(s.name.c_str(), (int)sx - w / 2 + 1, (int)sy + 6 + 1, font, Color{0, 0, 0, 190});
DrawText(s.name.c_str(), (int)sx - w / 2, (int)sy + 6, font,
t == SettleTier::City ? Color{250, 230, 150, 255} : Color{225, 210, 175, 255});
}
}
renderMap2D(); renderMap2D();
renderLiveInfo(); renderLiveInfo();
renderPanels(); renderPanels();

View File

@ -57,7 +57,11 @@ void Planet::buildGeometry() {
geoFeatures.clear(); sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u; sGeoSalt = 0; geoFeatures.clear(); sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u; sGeoSalt = 0;
sCellLand.assign(cells.size(), -1); sCellWater.assign(cells.size(), -1); sCellLand.assign(cells.size(), -1); sCellWater.assign(cells.size(), -1);
sCellRange.assign(cells.size(), -1); sCellRiver.assign(cells.size(), -1); sCellRange.assign(cells.size(), -1); sCellRiver.assign(cells.size(), -1);
ecoRegions.clear(); sEcoRng = cfg.seed ? (cfg.seed ^ 0xEC011FEu) : 0xEC011FEu;
sCellEcoregion.assign(cells.size(), -1);
volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au; volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
sCellSettlement.assign(cells.size(), -1); sHabitability.clear();
} }
void Planet::clearDerivedState() { void Planet::clearDerivedState() {

View File

@ -4,6 +4,8 @@
#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 "PlanetGeography.hpp" // FeatureKind, GeoFeature
#include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate
#include "PlanetEcoregions.hpp" // Ecoregion
#include <vector> #include <vector>
#include <memory> #include <memory>
#include <cstdint> #include <cstdint>
@ -18,6 +20,8 @@ public:
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+) std::vector<GeoFeature> geoFeatures; // named geographic features / the atlas (saved v17+)
std::vector<Ecoregion> ecoRegions; // named ecological provinces (saved v19+)
std::vector<Settlement> settlements; // civilization: settlements placed once, grow/decline (saved v20+)
// 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
@ -164,6 +168,26 @@ public:
const std::vector<int>& cellRange() const { return sCellRange; } // mountain-range 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) const std::vector<int>& cellRiver() const { return sCellRiver; } // river feature index (-1)
// Ecoregion atlas (PlanetEcoregions.cpp): connected ecological provinces built from current
// biome, land/water context, productivity and dominant broad biota. Saved (v19).
void generateEcoregions();
bool ecoregionsBuilt() const { return !ecoRegions.empty(); }
const std::vector<Ecoregion>& ecoregions() const { return ecoRegions; }
const std::vector<int>& cellEcoregion() const { return sCellEcoregion; } // ecoregion index (-1)
// Civilization Step 2 (PlanetCiv.cpp). computeHabitability() builds the derived per-cell
// habitability/food score (0..1; like climate, not saved). placeSettlements() seeds the fixed
// settlement set once on the best-spaced fertile cells (separate RNG; tectonic determinism intact;
// auto-builds geography/ecoregions for naming + productivity). stepCivilization() advances each
// settlement's population on the live clock toward a food-driven carrying capacity (grows / shrinks
// / is abandoned). Saved (v20); step-back restores populations via WeatherSnapshot.
void computeHabitability();
void placeSettlements();
CivUpdate stepCivilization(double dtHours);
bool settlementsPlaced() const { return !settlements.empty(); }
const std::vector<int>& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1)
const std::vector<double>& habitability() const { return sHabitability; } // 0..1 per cell (derived)
// 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;
@ -180,12 +204,14 @@ public:
// 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+); hasGeoSalt: // hasGeography: whether the stream carries the geography/atlas block (save v17+); hasGeoSalt:
// whether the stream carries the active reshuffle salt (save v18+). Older saves load with none // whether the stream carries the active reshuffle salt (save v18+); hasEcoregions: whether the
// (regenerated on demand), or with a deterministic first reshuffle if only the salt is absent. // stream carries the ecoregion atlas (save v19+). Older saves regenerate on demand.
// hasSettlements: whether the stream carries the civilization settlements block (save v20+); older
// saves load with none (re-seeded on demand via the civ key).
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 hasGeography = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true,
bool hasGeoSalt = true); bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -277,6 +303,16 @@ private:
uint32_t sGeoRng = 1; uint32_t sGeoRng = 1;
uint32_t sGeoSalt = 0; uint32_t sGeoSalt = 0;
// Ecoregions (saved v19). Per-cell ecoregion index (-1 = none) + separate RNG salt.
std::vector<int> sCellEcoregion;
uint32_t sEcoRng = 1;
// Civilization (saved v20). Per-cell settlement index (-1 = none; rebuilt on load, not saved),
// a derived habitability field, and a separate RNG so placement never perturbs tectonics.
std::vector<int> sCellSettlement;
std::vector<double> sHabitability;
uint32_t sCivRng = 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.
std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity; std::vector<double> sFloraDensity, sFaunaDensity, sFungaDensity;

134
src/sim/PlanetCiv.cpp Normal file
View File

@ -0,0 +1,134 @@
#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cmath>
#include <set>
// --- Civilization Step 2: settlements & habitability -------------------------
// A per-cell habitability/food score, a one-time placement of settlement point-agents on the best
// (well-spaced) cells, and their population growth/decline on the Live World clock toward a
// food-driven carrying capacity. The settlement SET is fixed after placement (one-time), so the only
// mutable per-step state is `population` -- which the step-back snapshot restores. Placement uses a
// SEPARATE RNG (sCivRng) so it never perturbs the tectonic stream (mirrors placeVolcanoes).
SettleTier settleTierOf(double pop, double townPop, double cityPop) {
if (pop >= cityPop) return SettleTier::City;
if (pop >= townPop) return SettleTier::Town;
return SettleTier::Village;
}
const char* settleTierName(SettleTier t) {
switch (t) { case SettleTier::City: return "City"; case SettleTier::Town: return "Town";
case SettleTier::Village: return "Village"; }
return "Village";
}
// Derived per-cell habitability (0..1): a weighted blend of temperature comfort, water access and
// food (flora/fauna + ecoregion productivity), gated by freezing winters + high elevation. Not saved.
void Planet::computeHabitability() {
const int n = (int)cells.size();
if ((int)sTemp.size() != n || (int)sMoist.size() != n) computeClimate();
if ((int)sFloraDensity.size() != n) computeBiotaDensity();
sHabitability.assign(n, 0.0);
const double sea = cfg.seaLevel;
const double wW = std::clamp(cfg.civHabWaterWeight, 0.0, 1.0);
const double fW = std::clamp(cfg.civHabFoodWeight, 0.0, 1.0);
const double tW = std::max(0.0, 1.0 - wW - fW);
const double tOpt = cfg.civHabTempOpt;
const bool haveDisch = (int)sDischarge.size() == n, haveLake = (int)sLakeDepth.size() == n;
const bool haveWinter = (int)sTempWinter.size() == n, haveEco = (int)sCellEcoregion.size() == n;
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || cells[i].biome == Biome::Ice) continue;
double mean = sTemp[i], winter = haveWinter ? sTempWinter[i] : mean;
double tComfort = std::clamp(1.0 - std::fabs(mean - tOpt) / 22.0, 0.0, 1.0);
double coldGate = std::clamp((winter + 25.0) / 20.0, 0.0, 1.0); // frozen winters (< -25C) -> 0
// Water access: moisture baseline, strong bonus for a river / lake / coast.
double water = std::clamp(0.25 + 0.6 * sMoist[i], 0.0, 1.0);
if (haveDisch && sDischarge[i] > cfg.riverThreshold) water = std::max(water, 0.92);
if (haveLake && sLakeDepth[i] > cfg.biomeLakeMinDepth) water = std::max(water, 0.85);
for (int j : cells[i].neighbors) if (cells[j].elevation <= sea) { water = std::max(water, 0.85); break; }
// Food: plant + animal productivity + the cell's ecoregion productivity.
double eco = 0.0;
if (haveEco && sCellEcoregion[i] >= 0 && sCellEcoregion[i] < (int)ecoRegions.size()) {
const Ecoregion& e = ecoRegions[sCellEcoregion[i]];
eco = std::max(e.floraProductivity, e.faunaProductivity);
}
double food = std::clamp(0.5 * sFloraDensity[i] + 0.3 * sFaunaDensity[i] + 0.2 * eco, 0.0, 1.0);
double elevF = 1.0;
if (cells[i].elevation > cfg.civHabElevPenalty)
elevF = std::clamp(1.0 - (cells[i].elevation - cfg.civHabElevPenalty) / 3000.0, 0.0, 1.0);
double hab = (wW * water + fW * food + tW * tComfort) * coldGate * elevF;
sHabitability[i] = std::clamp(hab, 0.0, 1.0);
}
}
// One-time placement: greedily seed the highest-habitability cells with a minimum angular spacing
// (the ocean-basin seeding idiom). Auto-builds geography/ecoregions first (names + productivity).
void Planet::placeSettlements() {
const int n = (int)cells.size();
if (!geographyBuilt()) generateGeography();
if (!ecoregionsBuilt()) generateEcoregions();
computeHabitability();
settlements.clear();
sCellSettlement.assign(n, -1);
sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
auto next = [&]() { sCivRng ^= sCivRng << 13; sCivRng ^= sCivRng >> 17; sCivRng ^= sCivRng << 5; return sCivRng; };
std::vector<int> cand;
for (int i = 0; i < n; ++i) if (sHabitability[i] >= cfg.civMinHabitability) cand.push_back(i);
std::sort(cand.begin(), cand.end(), [&](int a, int b) { return sHabitability[a] > sHabitability[b]; });
const double sepCos = std::cos(std::max(0.01, cfg.civMinSpacingRadians));
const int cap = std::max(0, cfg.civMaxSettlements);
std::set<std::string> usedNames;
std::vector<int> chosen;
for (int i : cand) {
if ((int)settlements.size() >= cap) break;
bool ok = true;
for (int c : chosen) if (cells[i].unit.dot(cells[c].unit) > sepCos) { ok = false; break; }
if (!ok) continue;
chosen.push_back(i);
int regId = ((int)sCellLand.size() == n) ? sCellLand[i] : -1;
int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId)
: namegen::bankForRegion(cfg.seed, 2000 + i);
uint32_t nameSeed = next() ^ (uint32_t)(i * 2654435761u);
std::string nm = namegen::makeName(nameSeed, bank);
for (int g = 0; usedNames.count(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank);
usedNames.insert(nm);
Settlement st;
st.cell = i; st.bank = bank; st.regionId = regId;
st.population = cfg.civSeedPopulation; st.name = nm;
settlements.push_back(std::move(st));
}
for (int k = 0; k < (int)settlements.size(); ++k) {
settlements[k].id = (uint32_t)(k + 1);
sCellSettlement[settlements[k].cell] = k;
}
}
// One live-frame civilization step: each settlement's population moves logistically toward its
// food-driven carrying capacity K = civMaxPopulation * habitability (cut transiently where an active
// volcano ashes the area). Grows below K, declines above it; floored at 1 so an abandoned site can
// revive if K recovers. Pure of any string churn; the set never changes here.
CivUpdate Planet::stepCivilization(double dtHours) {
CivUpdate up;
if (settlements.empty() || dtHours <= 0.0) return up;
const int n = (int)cells.size();
if ((int)sHabitability.size() != n) computeHabitability();
const double yearHours = std::max(1.0, cfg.dayLengthHours * cfg.yearLengthDays);
const double dtYears = dtHours / yearHours;
for (Settlement& st : settlements) {
if (st.cell < 0 || st.cell >= n) continue;
double K = cfg.civMaxPopulation * sHabitability[st.cell];
for (const Volcano& v : volcanoes) { // active ash plume nearby cuts carrying capacity
if (v.ashTimer <= 0.0 || v.cell < 0 || v.cell >= n) continue;
double ang = std::acos(std::clamp(cells[st.cell].unit.dot(cells[v.cell].unit), -1.0, 1.0));
if (ang < cfg.volcanoBlastRadius * 1.5) { K *= 0.3; break; }
}
double P = st.population;
P += cfg.civGrowthRate * P * (1.0 - P / std::max(1.0, K)) * dtYears; // logistic (declines when K<P)
P = std::max(1.0, P); // keep a seed so an abandoned site can revive
if (std::fabs(P - st.population) > std::max(1.0, st.population * 0.0005)) up.recolor = true;
st.population = P;
}
return up;
}

28
src/sim/PlanetCiv.hpp Normal file
View File

@ -0,0 +1,28 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
// Civilization stage, Step 2: settlements + habitability. A Settlement is a point agent fixed to one
// cell (the cell is the territory unit, ~223 km). Placed once on a settled world ("dawn of
// civilization"), then its population grows toward a food-driven carrying capacity on the Live World
// clock and shrinks / is abandoned when food drops. Raylib-free + deterministic (separate RNG). The
// set is fixed after placement, so the only mutable per-step state is `population`. Saved (v20).
enum class SettleTier : uint8_t { Village, Town, City };
struct Settlement {
uint32_t id = 0;
int cell = -1; // the grid cell it sits on (fixed geometry)
int bank = 0; // NameGen "language" bank (its continent's), kept for later culture use
int regionId = -1; // containing continent/island geography-feature index (-1 = none)
double population = 0.0; // the only mutable field (grows/declines each live step)
std::string name;
};
// What stepCivilization() changed this call, telling the viewer how much to rebuild + whether to log.
struct CivUpdate { bool recolor = false; bool changed = false; };
// Tier from population + the config thresholds (village < townPop <= town < cityPop <= city).
SettleTier settleTierOf(double population, double townPop, double cityPop);
const char* settleTierName(SettleTier t); // "Village" / "Town" / "City"

View File

@ -0,0 +1,208 @@
#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cmath>
#include <set>
#include <string>
#include <vector>
// --- Ecoregions --------------------------------------------------------------
// Named ecological provinces: connected areas with compatible biome, land/water
// context and productivity. This is an atlas layer, not a live ecological sim.
namespace {
enum EcoContext { EcoOcean = 0, EcoLand = 1, EcoWet = 2 };
uint32_t ecoHash(uint32_t seed, int biome, int context, int anchor) {
uint32_t h = (seed ^ 0xEC011FEu) + (uint32_t)biome * 0x85EBCA6Bu
+ (uint32_t)context * 0xC2B2AE35u;
h ^= (uint32_t)(anchor * 2654435761u + 0x165667B1u);
h ^= h >> 15; h *= 0x2545F491u; h ^= h >> 13;
return h ? h : 1u;
}
const char* ecoSuffix(Biome b, int context, double temp, double moist,
int domFlora, int domFauna) {
const auto& AR = biotaArchetypes();
auto archName = [&](int idx) -> std::string {
return (idx >= 0 && idx < (int)AR.size()) ? std::string(AR[idx].name) : std::string();
};
if (context == EcoOcean) {
std::string fl = archName(domFlora), fa = archName(domFauna);
if (fl == "Kelp") return "Kelp Shelf";
if (fa == "Reef fish" || (temp > 18.0 && moist > 0.35)) return "Reef";
if (fa == "Baleen whale") return "Whale Grounds";
return "Marine Province";
}
if (context == EcoWet || b == Biome::Lake || b == Biome::Wetland) return "Wetlands";
switch (b) {
case Biome::Beach: return "Coast";
case Biome::Grassland: return "Grasslands";
case Biome::Savanna: return "Savanna";
case Biome::Desert: return "Desert";
case Biome::Forest: return (temp > 18.0 && moist > 0.55) ? "Rainforest" : "Forest";
case Biome::Taiga: return "Taiga";
case Biome::Tundra: return "Tundra";
case Biome::Hills: return "Highlands";
case Biome::Mountains: return "Alpine Zone";
default: return "Ecoregion";
}
}
}
void Planet::generateEcoregions() {
const int n = (int)cells.size();
if (n == 0) return;
if ((int)sTemp.size() != n || (int)sMoist.size() != n) computeClimate();
classifyBiomes();
if ((int)sFloraDensity.size() != n || (int)sFaunaDensity.size() != n ||
(int)sFungaDensity.size() != n) computeBiotaDensity();
if ((int)sLakeDepth.size() != n) computeHydrology();
if (!geographyBuilt()) generateGeography();
ecoRegions.clear();
sCellEcoregion.assign(n, -1);
sEcoRng = cfg.seed ? (cfg.seed ^ 0xEC011FEu) : 0xEC011FEu;
auto contextOf = [&](int i) {
if (cells[i].elevation <= cfg.seaLevel) return EcoOcean;
if (cells[i].biome == Biome::Wetland || cells[i].biome == Biome::Lake ||
(!sLakeDepth.empty() && sLakeDepth[i] > cfg.biomeLakeMinDepth)) return EcoWet;
return EcoLand;
};
auto productivity = [&](int i) {
return std::max({ sFloraDensity[i], sFaunaDensity[i], sFungaDensity[i] });
};
auto bandOf = [&](int i) {
return std::clamp((int)std::floor(productivity(i) * 4.0), 0, 3);
};
auto sameKey = [&](int a, int b) {
if (cells[a].biome == Biome::Ice || cells[b].biome == Biome::Ice) return false;
return cells[a].biome == cells[b].biome && contextOf(a) == contextOf(b)
&& std::abs(bandOf(a) - bandOf(b)) <= 1;
};
std::vector<std::vector<int>> comps;
std::vector<char> seen(n, 0);
std::vector<int> stack;
for (int i = 0; i < n; ++i) {
if (seen[i] || cells[i].biome == Biome::Ice) 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] && sameKey(u, v)) { seen[v] = 1; stack.push_back(v); }
}
comps.push_back(std::move(comp));
}
std::sort(comps.begin(), comps.end(), [](const auto& a, const auto& b) { return a.size() > b.size(); });
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;
};
auto dominant = [&](const std::vector<int>& comp, BiotaKind kind) {
const auto& AR = biotaArchetypes();
std::vector<double> score(AR.size(), 0.0);
if (sHasBiota && sBiota.size() == cells.size()) {
for (int i : comp) {
const std::vector<Organism>* list = nullptr;
if (kind == BiotaKind::Flora) list = &sBiota[i].flora;
else if (kind == BiotaKind::Fauna) list = &sBiota[i].fauna;
else list = &sBiota[i].funga;
for (const Organism& o : *list)
if (o.archetype < AR.size()) score[o.archetype] += pointCost(AR[o.archetype].size);
}
} else {
for (int i : comp) {
double dens = (kind == BiotaKind::Flora) ? sFloraDensity[i]
: (kind == BiotaKind::Fauna) ? sFaunaDensity[i] : sFungaDensity[i];
if (dens <= 0.0) continue;
for (size_t a = 0; a < AR.size(); ++a) {
if (AR[a].kind != kind) continue;
score[a] += dens * biotaSuitability(AR[a], cells[i].biome, sTemp[i], sMoist[i]);
}
}
}
int best = -1; double bs = 0.0;
for (size_t a = 0; a < score.size(); ++a)
if (score[a] > bs) { bs = score[a]; best = (int)a; }
return best;
};
auto regionOf = [&](int anchor, int context) {
const std::vector<int>& arr = (context == EcoOcean) ? sCellWater : sCellLand;
if (anchor >= 0 && anchor < (int)arr.size()) return arr[anchor];
return -1;
};
auto updateProductivity = [&](Ecoregion& e, const std::vector<int>& comp, int oldSize) {
double fl = 0.0, fa = 0.0, fu = 0.0;
for (int i : comp) { fl += sFloraDensity[i]; fa += sFaunaDensity[i]; fu += sFungaDensity[i]; }
int add = (int)comp.size();
int total = oldSize + add;
if (total <= 0) return;
e.floraProductivity = (e.floraProductivity * oldSize + fl) / total;
e.faunaProductivity = (e.faunaProductivity * oldSize + fa) / total;
e.fungaProductivity = (e.fungaProductivity * oldSize + fu) / total;
e.size = total;
};
std::set<std::string> usedNames;
auto makeName = [&](Biome b, int context, int anchor, int regId, int domFlora, int domFauna,
double temp, double moist) {
int bank = namegen::bankForRegion(cfg.seed ^ 0xEC011FEu, regId >= 0 ? regId : 3000 + anchor);
uint32_t seed = ecoHash(cfg.seed, (int)b, context, anchor);
std::string suffix = ecoSuffix(b, context, temp, moist, domFlora, domFauna);
std::string root = namegen::makeName(seed, bank);
std::string out = root + " " + suffix;
for (int guard = 0; usedNames.count(out) && guard < 128; ++guard) {
seed += 0x9E3779B9u;
root = namegen::makeName(seed, bank);
out = root + " " + suffix;
}
usedNames.insert(out);
return out;
};
const int tinyMax = 3;
for (const auto& comp : comps) {
int anchor = centroidCell(comp);
int context = contextOf(anchor);
if ((int)comp.size() <= tinyMax) {
int merge = -1;
for (int c : comp) {
for (int nb : cells[c].neighbors) {
int ei = (nb >= 0 && nb < (int)sCellEcoregion.size()) ? sCellEcoregion[nb] : -1;
if (ei >= 0 && ecoRegions[ei].biome == cells[anchor].biome) { merge = ei; break; }
}
if (merge >= 0) break;
}
if (merge >= 0) {
int oldSize = ecoRegions[merge].size;
updateProductivity(ecoRegions[merge], comp, oldSize);
for (int c : comp) sCellEcoregion[c] = merge;
continue;
}
}
Ecoregion e;
e.id = (uint32_t)ecoRegions.size() + 1;
e.biome = cells[anchor].biome;
e.anchorCell = anchor;
e.regionId = regionOf(anchor, context);
e.dominantFlora = dominant(comp, BiotaKind::Flora);
e.dominantFauna = dominant(comp, BiotaKind::Fauna);
e.dominantFunga = (context == EcoOcean) ? -1 : dominant(comp, BiotaKind::Funga);
updateProductivity(e, comp, 0);
e.name = makeName(e.biome, context, anchor, e.regionId, e.dominantFlora, e.dominantFauna,
sTemp[anchor], sMoist[anchor]);
int ei = (int)ecoRegions.size();
ecoRegions.push_back(std::move(e));
for (int c : comp) sCellEcoregion[c] = ei;
}
}

View File

@ -0,0 +1,22 @@
#pragma once
#include "PlanetTypes.hpp" // Biome
#include <cstdint>
#include <string>
// Ecoregions are named ecological provinces: connected cells with compatible biome,
// water/land context and productivity. They summarize existing broad biota; they do
// not create new species.
struct Ecoregion {
uint32_t id = 0;
std::string name;
Biome biome = Biome::Ocean;
int anchorCell = -1;
int regionId = -1; // containing geography feature index, if known
int size = 0; // member-cell count
int dominantFlora = -1; // biotaArchetypes() index, or -1
int dominantFauna = -1;
int dominantFunga = -1;
double floraProductivity = 0.0;
double faunaProductivity = 0.0;
double fungaProductivity = 0.0;
};

View File

@ -49,12 +49,15 @@
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) D(geoOceanSepRadians) \ D(geoMountainElev) D(geoRiverMinDischarge) D(geoOceanSepRadians) \
D(civMinSpacingRadians) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \
D(civMaxPopulation) D(civTownPop) D(civCityPop) D(civAbandonPop) \
D(civHabWaterWeight) D(civHabFoodWeight) D(civHabTempOpt) D(civHabElevPenalty) \
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(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) \ I(geoOceanDeep) I(civMaxSettlements) \
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)
@ -248,6 +251,18 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.geoMountainElev, 0.0, 12000.0, "geoMountainElev")); E(rng(cfg.geoMountainElev, 0.0, 12000.0, "geoMountainElev"));
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge")); E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians")); E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians"));
E(rng(cfg.civMinSpacingRadians, 0.001, 3.14159, "civMinSpacingRadians"));
E(rng(cfg.civMinHabitability, 0.0, 1.0, "civMinHabitability"));
E(rng(cfg.civSeedPopulation, 1.0, 1.0e9, "civSeedPopulation"));
E(rng(cfg.civGrowthRate, 0.0, 100.0, "civGrowthRate"));
E(rng(cfg.civMaxPopulation, 1.0, 1.0e12, "civMaxPopulation"));
E(rng(cfg.civTownPop, 1.0, 1.0e12, "civTownPop"));
E(rng(cfg.civCityPop, 1.0, 1.0e12, "civCityPop"));
E(rng(cfg.civAbandonPop, 0.0, 1.0e9, "civAbandonPop"));
E(rng(cfg.civHabWaterWeight, 0.0, 1.0, "civHabWaterWeight"));
E(rng(cfg.civHabFoodWeight, 0.0, 1.0, "civHabFoodWeight"));
E(rng(cfg.civHabTempOpt, -20.0, 50.0, "civHabTempOpt"));
E(rng(cfg.civHabElevPenalty, 0.0, 12000.0, "civHabElevPenalty"));
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"));
@ -268,6 +283,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.geoMaxRivers, 0, 100000, "geoMaxRivers")); E(irng(cfg.geoMaxRivers, 0, 100000, "geoMaxRivers"));
E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks")); E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks"));
E(irng(cfg.geoOceanDeep, 1, 1000, "geoOceanDeep")); E(irng(cfg.geoOceanDeep, 1, 1000, "geoOceanDeep"));
E(irng(cfg.civMaxSettlements, 0, 1000000, "civMaxSettlements"));
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"));
@ -393,17 +409,40 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, sCellLand); writeVec(os, sCellWater); writeVec(os, sCellLand); writeVec(os, sCellWater);
writeVec(os, sCellRange); writeVec(os, sCellRiver); writeVec(os, sCellRange); writeVec(os, sCellRiver);
writePod(os, sGeoSalt); writePod(os, sGeoSalt);
// v19: named ecoregions + per-cell ecoregion index.
uint64_t ne = ecoRegions.size(); writePod(os, ne);
for (const Ecoregion& e : ecoRegions) {
writePod(os, e.id);
uint8_t b = (uint8_t)e.biome; writePod(os, b);
writePod(os, e.anchorCell); writePod(os, e.regionId); writePod(os, e.size);
writePod(os, e.dominantFlora); writePod(os, e.dominantFauna); writePod(os, e.dominantFunga);
writePod(os, e.floraProductivity); writePod(os, e.faunaProductivity); writePod(os, e.fungaProductivity);
uint64_t L = e.name.size(); writePod(os, L);
if (L) os.write(e.name.data(), (std::streamsize)L);
}
writeVec(os, sCellEcoregion);
// v20: civilization settlements (placed once, then grow/decline). sCellSettlement is rebuilt on
// load, so only the settlement records (id/cell/bank/regionId/population/name) are written.
uint64_t ns = settlements.size(); writePod(os, ns);
for (const Settlement& st : settlements) {
writePod(os, st.id); writePod(os, st.cell); writePod(os, st.bank); writePod(os, st.regionId);
writePod(os, st.population);
uint64_t L = st.name.size(); writePod(os, L);
if (L) os.write(st.name.data(), (std::streamsize)L);
}
} }
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, bool hasGeoSalt) { bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements) {
// 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 if (!hasVolcanoes || !hasStatefulVolcanoes) hasGeography = false; // geography block follows the volcano block
if (!hasGeography) hasGeoSalt = false; // salt follows the geography block if (!hasGeography) hasGeoSalt = false; // salt follows the geography block
if (!hasGeoSalt) hasEcoregions = false; // ecoregions follow the v18 salt
if (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion 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
@ -570,6 +609,54 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
sGeoRng = sGeoSalt ? sGeoSalt : (cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u); sGeoRng = sGeoSalt ? sGeoSalt : (cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u);
} }
} }
// v19: named ecoregions. Older saves load with none (regenerate on demand).
ecoRegions.clear(); sEcoRng = cfg.seed ? (cfg.seed ^ 0xEC011FEu) : 0xEC011FEu;
sCellEcoregion.assign(cells.size(), -1);
if (hasEcoregions) {
uint64_t ne = 0; readPod(is, ne);
if (!is || ne > 200000) return false;
ecoRegions.resize((size_t)ne);
for (Ecoregion& e : ecoRegions) {
readPod(is, e.id);
uint8_t b = 0; readPod(is, b);
if (!validBiomeByte(b)) return false;
e.biome = (Biome)b;
readPod(is, e.anchorCell); readPod(is, e.regionId); readPod(is, e.size);
readPod(is, e.dominantFlora); readPod(is, e.dominantFauna); readPod(is, e.dominantFunga);
readPod(is, e.floraProductivity); readPod(is, e.faunaProductivity); readPod(is, e.fungaProductivity);
uint64_t L = 0; readPod(is, L);
if (!is || L > 256) return false;
e.name.resize((size_t)L);
if (L) is.read(&e.name[0], (std::streamsize)L);
if (!is || e.anchorCell < 0 || e.anchorCell >= (int)cells.size() || e.size < 0) return false;
auto validArch = [](int a) { return a == -1 || (a >= 0 && a < (int)biotaArchetypes().size()); };
if (!validArch(e.dominantFlora) || !validArch(e.dominantFauna) || !validArch(e.dominantFunga)) return false;
if (!std::isfinite(e.floraProductivity) || !std::isfinite(e.faunaProductivity)
|| !std::isfinite(e.fungaProductivity)) return false;
}
if (!readVec(is, sCellEcoregion, cells.size())) return false;
if (sCellEcoregion.empty()) sCellEcoregion.assign(cells.size(), -1);
if (sCellEcoregion.size() != cells.size()) return false;
for (int v : sCellEcoregion) if (v < -1 || v >= (int)ecoRegions.size()) return false;
}
// v20: civilization settlements. buildGeometry() already cleared them; older saves stay empty
// (re-seeded on demand). sCellSettlement is rebuilt from the records (not stored).
if (hasSettlements) {
uint64_t ns = 0; readPod(is, ns);
if (!is || ns > 1000000) return false;
settlements.resize((size_t)ns);
for (Settlement& st : settlements) {
readPod(is, st.id); readPod(is, st.cell); readPod(is, st.bank); readPod(is, st.regionId);
readPod(is, st.population);
uint64_t L = 0; readPod(is, L);
if (!is || L > 256) return false;
st.name.resize((size_t)L);
if (L) is.read(&st.name[0], (std::streamsize)L);
if (!is || st.cell < 0 || st.cell >= (int)cells.size() || !std::isfinite(st.population)) return false;
}
sCellSettlement.assign(cells.size(), -1);
for (int k = 0; k < (int)settlements.size(); ++k) sCellSettlement[settlements[k].cell] = k;
}
computeBiotaDensity(); // derived density scalars for the colour views computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; return (bool)is;
} }

View File

@ -95,6 +95,9 @@ struct WeatherSnapshot {
std::vector<Volcano> volcanoes; std::vector<Volcano> volcanoes;
uint32_t rng = 0, nextId = 0; uint32_t rng = 0, nextId = 0;
uint32_t volRng = 0; uint32_t volRng = 0;
// Civilization: settlement populations (the only mutable per-step civ state, since the set is
// fixed after placement). Restored on a step back so towns rewind/replay with the clock.
std::vector<double> settlementPop;
}; };
struct Plate { struct Plate {
@ -374,4 +377,22 @@ struct PlanetConfig {
double geoRiverMinDischarge = 80.0; // min mouth discharge for a named river double geoRiverMinDischarge = 80.0; // min mouth discharge for a named river
int geoMaxRivers = 40; // cap on named rivers (largest by discharge) int geoMaxRivers = 40; // cap on named rivers (largest by discharge)
int geoMaxPeaks = 40; // cap on named peaks (highest) int geoMaxPeaks = 40; // cap on named peaks (highest)
// --- Civilization: settlements & habitability -- see PlanetCiv.cpp ----------
// Placed once on a settled world ("dawn of civilization"); population then grows/declines on the
// Live World clock toward a food-driven carrying capacity. Habitability blends climate comfort,
// water access and food (flora/fauna + ecoregion productivity).
int civMaxSettlements = 80; // cap on settlement sites
double civMinSpacingRadians = 0.10; // min angular separation between settlement sites (~640 km)
double civMinHabitability = 0.22; // don't place a settlement below this habitability
double civSeedPopulation = 250.0; // initial village population at placement
double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity)
double civMaxPopulation = 2.0e6; // population at habitability 1 (carrying-capacity scale)
double civTownPop = 5000.0; // population at/above which a settlement is a Town
double civCityPop = 100000.0;// population at/above which a settlement is a City
double civAbandonPop = 50.0; // below this a settlement is abandoned (dormant; can revive)
double civHabWaterWeight = 0.45; // habitability weight of water access (rivers/lakes/coast)
double civHabFoodWeight = 0.40; // habitability weight of food (flora/fauna + ecoregion)
double civHabTempOpt = 18.0; // C: most comfortable annual-mean temperature
double civHabElevPenalty = 2500.0; // m above which high terrain steeply reduces habitability
}; };

View File

@ -31,6 +31,8 @@ WeatherSnapshot Planet::captureWeather() const {
s.humidity = sHumidity; s.cloud = sCloud; s.rain = sRain; s.humidity = sHumidity; s.cloud = sCloud; s.rain = sRain;
s.storms = sStorms; s.rng = sWeatherRng; s.nextId = sStormNextId; s.storms = sStorms; s.rng = sWeatherRng; s.nextId = sStormNextId;
s.volcanoes = volcanoes; s.volRng = sVolRng; s.volcanoes = volcanoes; s.volRng = sVolRng;
s.settlementPop.reserve(settlements.size()); // civ: only population is mutable
for (const Settlement& st : settlements) s.settlementPop.push_back(st.population);
return s; return s;
} }
@ -38,6 +40,8 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain; sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain;
sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId; sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId;
volcanoes = s.volcanoes; sVolRng = s.volRng; volcanoes = s.volcanoes; sVolRng = s.volRng;
if (s.settlementPop.size() == settlements.size()) // restore populations (set is fixed)
for (size_t k = 0; k < settlements.size(); ++k) settlements[k].population = s.settlementPop[k];
sHasWeather = !sHumidity.empty(); sHasWeather = !sHumidity.empty();
} }

151
test_civ.cpp Normal file
View File

@ -0,0 +1,151 @@
// Headless test for civilization Step 2 (habitability + settlements). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_civ.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/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp \
// src/sim/PlanetIO.cpp -o /tmp/tc && /tmp/tc
//
// Verifies: habitability range/zeros; placement spacing/cap/land + unique names; food-driven growth
// and decline; tiers; determinism + RNG isolation; population snapshot round-trip; v20 save; reseed clear.
#include "Planet.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); if (k >= iters/2) p.hydrology(dt*0.2); }
p.computeClimate(); p.classifyBiomes();
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 4242;
Planet p; p.generate(cfg); settle(p); drift(p, 400);
const int n = (int)p.cells.size();
const double sea = p.cfg.seaLevel;
const double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
std::printf("Civ: habitability field\n");
p.computeHabitability();
const auto& H = p.habitability();
check((int)H.size() == n, "habitability sized n");
bool ranged = true, zeroWaterIce = true, anyHabitable = false;
for (int i = 0; i < n; ++i) {
if (!(std::isfinite(H[i]) && H[i] >= 0.0 && H[i] <= 1.0)) ranged = false;
if ((p.cells[i].elevation <= sea || p.cells[i].biome == Biome::Ice) && H[i] != 0.0) zeroWaterIce = false;
if (H[i] > 0.3) anyHabitable = true;
}
check(ranged, "habitability in [0,1]");
check(zeroWaterIce, "habitability 0 on ocean/ice");
check(anyHabitable, "some land is habitable");
std::printf("Civ: placement\n");
p.placeSettlements();
const auto& S = p.settlements;
check(!S.empty(), "settlements placed");
bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements;
std::set<std::string> names; bool uniqueNames = true;
const double sepCos = std::cos(p.cfg.civMinSpacingRadians);
bool spaced = true;
for (size_t a = 0; a < S.size(); ++a) {
if (p.cells[S[a].cell].elevation <= sea) onLand = false;
if (p.habitability()[S[a].cell] < p.cfg.civMinHabitability - 1e-9) aboveMin = false;
if (!names.insert(S[a].name).second || S[a].name.empty()) uniqueNames = false;
for (size_t b = a + 1; b < S.size(); ++b)
if (p.cells[S[a].cell].unit.dot(p.cells[S[b].cell].unit) > sepCos + 1e-9) spaced = false;
if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency
}
std::printf(" %d settlements\n", (int)S.size());
check(onLand, "settlements sit on land + cellSettlement index is consistent");
check(aboveMin, "settlements only on cells >= civMinHabitability");
check(spaced, "settlements respect the minimum spacing");
check(capOk, "settlement count within the cap");
check(uniqueNames, "settlement names are unique + non-empty");
std::printf("Civ: food-driven growth + decline\n");
{
int gi = 0; for (size_t k = 0; k < S.size(); ++k) if (p.habitability()[S[k].cell] > p.habitability()[S[gi].cell]) gi = (int)k;
double p0 = p.settlements[gi].population;
for (int k = 0; k < 400; ++k) p.stepCivilization(5.0 * yearH); // ~2000 yr of small steps
check(p.settlements[gi].population > p0 * 2.0, "a high-habitability settlement grows");
// Decline: push one well over its carrying capacity, then step -> it shrinks.
p.settlements[gi].population = 5.0e7;
double over = p.settlements[gi].population;
for (int k = 0; k < 400; ++k) p.stepCivilization(5.0 * yearH);
check(p.settlements[gi].population < over, "an over-capacity settlement declines toward its food limit");
}
std::printf("Civ: tiers\n");
check(settleTierOf(100.0, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Village
&& settleTierOf(p.cfg.civTownPop, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Town
&& settleTierOf(p.cfg.civCityPop, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::City,
"tier thresholds (village/town/city)");
std::printf("Civ: determinism\n");
Planet q; q.generate(cfg); settle(q); drift(q, 400); q.placeSettlements();
bool same = (q.settlements.size() == S.size());
if (same) for (size_t k = 0; k < S.size(); ++k)
if (q.settlements[k].cell != p.settlements[k].cell || q.settlements[k].name != p.settlements[k].name) { same = false; break; }
check(same, "placeSettlements is deterministic");
std::printf("Civ: 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 dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx);
double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy);
if (k == 20) { y.placeSettlements(); y.stepCivilization(yearH); }
}
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, "placeSettlements/stepCivilization never perturb tectonic evolution");
std::printf("Civ: population snapshot round-trip\n");
{
WeatherSnapshot snap = p.captureWeather();
for (auto& st : p.settlements) st.population = 12345.0;
p.restoreWeather(snap);
bool restored = true;
for (size_t k = 0; k < p.settlements.size(); ++k) if (std::fabs(p.settlements[k].population - snap.settlementPop[k]) > 1e-9) restored = false;
check(snap.settlementPop.size() == p.settlements.size() && restored, "captureWeather/restoreWeather round-trips populations");
}
std::printf("Civ: save v20 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, true, true, true);
check(ok, "readState accepts a v20 stream");
bool match = (r.settlements.size() == p.settlements.size());
if (match) for (size_t k = 0; k < p.settlements.size(); ++k)
if (r.settlements[k].cell != p.settlements[k].cell || r.settlements[k].name != p.settlements[k].name
|| std::fabs(r.settlements[k].population - p.settlements[k].population) > 1e-6) { match = false; break; }
check(match, "settlements round-trip through save");
check(r.cellSettlement() == p.cellSettlement(), "cellSettlement index rebuilt on load");
}
std::printf("Civ: reseed clears settlements\n");
p.generate(cfg);
check(p.settlements.empty() && (p.cellSettlement().empty() || p.cellSettlement()[0] == -1), "reseed clears the settlement set");
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CIV CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}

131
test_ecoregions.cpp Normal file
View File

@ -0,0 +1,131 @@
// Headless test for the ecoregion atlas. No display needed.
#include "Planet.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();
p.computeBiotaDensity();
}
static bool sameBiota(const std::vector<CellBiota>& a, const std::vector<CellBiota>& b) {
if (a.size() != b.size()) return false;
auto eq = [](const std::vector<Organism>& x, const std::vector<Organism>& y) {
if (x.size() != y.size()) return false;
for (size_t k = 0; k < x.size(); ++k)
if (x[k].archetype != y[k].archetype || x[k].biome != y[k].biome) return false;
return true;
};
for (size_t i = 0; i < a.size(); ++i)
if (!eq(a[i].flora, b[i].flora) || !eq(a[i].fauna, b[i].fauna) || !eq(a[i].funga, b[i].funga))
return false;
return true;
}
int main() {
PlanetConfig cfg; cfg.seed = 9191; cfg.subdivisions = 5;
Planet p; p.generate(cfg); settle(p);
const int n = (int)p.cells.size();
std::printf("Ecoregions: extraction\n");
p.generateEcoregions();
const auto& E = p.ecoregions();
const auto& ce = p.cellEcoregion();
check(!E.empty(), "generateEcoregions produces regions");
check((int)ce.size() == n, "per-cell ecoregion array sized n");
bool mapped = true, indexOk = true, anchorsOk = true, namesOk = true, domOk = true;
std::set<std::string> names;
for (int i = 0; i < n; ++i) {
if (p.cells[i].biome != Biome::Ice && ce[i] < 0) mapped = false;
if (ce[i] >= (int)E.size()) indexOk = false;
}
for (const Ecoregion& e : E) {
if (e.name.empty() || !names.insert(e.name).second) namesOk = false;
if (e.anchorCell < 0 || e.anchorCell >= n || p.cells[e.anchorCell].biome != e.biome
|| e.biome == Biome::Ice) anchorsOk = false;
auto validArch = [](int a) { return a == -1 || (a >= 0 && a < (int)biotaArchetypes().size()); };
if (!validArch(e.dominantFlora) || !validArch(e.dominantFauna) || !validArch(e.dominantFunga)) domOk = false;
if (!std::isfinite(e.floraProductivity) || !std::isfinite(e.faunaProductivity)
|| !std::isfinite(e.fungaProductivity)) domOk = false;
}
check(mapped, "every non-ice cell maps to an ecoregion");
check(indexOk, "per-cell ecoregion indices are valid");
check(anchorsOk, "ecoregion anchors match their biome");
check(namesOk, "ecoregion names are unique and non-empty");
check(domOk, "dominants/productivity are valid");
std::printf("Ecoregions: determinism\n");
Planet q; q.generate(cfg); settle(q); q.generateEcoregions();
bool same = (q.ecoregions().size() == E.size()) && (q.cellEcoregion() == ce);
if (same)
for (size_t i = 0; i < E.size(); ++i) {
const Ecoregion& a = E[i]; const Ecoregion& b = q.ecoregions()[i];
if (a.name != b.name || a.biome != b.biome || a.anchorCell != b.anchorCell
|| a.size != b.size || a.dominantFlora != b.dominantFlora
|| a.dominantFauna != b.dominantFauna || a.dominantFunga != b.dominantFunga) {
same = false; break;
}
}
check(same, "same seed produces identical ecoregions");
std::printf("Ecoregions: RNG isolation\n");
{
Planet a; a.generate(cfg); settle(a); a.drifting = true;
Planet b; b.generate(cfg); settle(b); b.drifting = true; b.generateEcoregions();
double dt = a.cflDtMy();
for (int k = 0; k < 5; ++k) { a.advect(dt); a.step(); a.erode(dt);
b.advect(dt); b.step(); b.erode(dt); }
bool terrainSame = a.cells.size() == b.cells.size();
for (size_t i = 0; terrainSame && i < a.cells.size(); ++i)
if (a.cells[i].elevation != b.cells[i].elevation || a.cells[i].plateId != b.cells[i].plateId)
terrainSame = false;
check(terrainSame, "ecoregions do not perturb tectonic evolution");
}
{
Planet a; a.generate(cfg); settle(a); a.generateBiota();
Planet b; b.generate(cfg); settle(b); b.generateEcoregions(); b.generateBiota();
check(a.biotaPopulated() && b.biotaPopulated() && sameBiota(a.biota(), b.biota()),
"ecoregions do not perturb biota generation");
}
std::printf("Ecoregions: save v19 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);
check(ok, "readState accepts a v19 stream");
bool match = (r.ecoregions().size() == E.size()) && (r.cellEcoregion() == ce);
if (match)
for (size_t i = 0; i < E.size(); ++i)
if (r.ecoregions()[i].name != E[i].name || r.ecoregions()[i].biome != E[i].biome
|| r.ecoregions()[i].anchorCell != E[i].anchorCell) { match = false; break; }
check(match, "ecoregions round-trip through save");
ss.clear(); ss.seekg(0);
Planet old;
bool okOld = old.readState(ss, true, true, true, true, true, true, true, true, true, false);
check(okOld && old.ecoregions().empty(), "pre-v19 read leaves ecoregions empty");
}
std::printf("\n%s (%d failure%s)\n", failures ? "FAILURES" : "ALL ECOREGION CHECKS PASSED",
failures, failures == 1 ? "" : "s");
return failures ? 1 : 0;
}

View File

@ -9,7 +9,7 @@
// src/sim/PlanetGeography.cpp src/sim/PlanetIO.cpp -o /tmp/tg && /tmp/tg // src/sim/PlanetGeography.cpp src/sim/PlanetIO.cpp -o /tmp/tg && /tmp/tg
// //
// Verifies: extraction (continents/oceans/ranges/rivers/lakes), per-cell membership consistency, // Verifies: extraction (continents/oceans/ranges/rivers/lakes), per-cell membership consistency,
// names non-empty/unique/deterministic, RNG isolation from tectonics, and a v18 save round-trip. // names non-empty/unique/deterministic, RNG isolation from tectonics, and a geography save round-trip.
#include "Planet.hpp" #include "Planet.hpp"
#include "NameGen.hpp" #include "NameGen.hpp"
@ -226,13 +226,13 @@ int main() {
for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) terrainSame = false; 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"); check(terrainSame, "generateGeography never perturbs tectonic evolution");
std::printf("Geography: save v18 round-trip\n"); std::printf("Geography: save round-trip\n");
{ {
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss); p.writeState(ss);
Planet r; Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true, true, true); bool ok = r.readState(ss, true, true, true, true, true, true, true, true);
check(ok, "readState accepts a v18 stream"); check(ok, "readState accepts a geography stream");
bool match = (r.geography().size() == F.size()); bool match = (r.geography().size() == F.size());
if (match) if (match)
for (size_t i = 0; i < F.size(); ++i) for (size_t i = 0; i < F.size(); ++i)