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)
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)
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)
. / , step the live clock forward / back by one rate-unit (auto-pauses; back also
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)
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)
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/PlanetBiota.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
# Biota / Live World / Ocean / Weather / Volcano / Geography suites: same source list, swap
# test_logic.cpp -> test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp,
# test_volcano.cpp or test_geography.cpp.
# Same source list for every suite: swap test_logic.cpp -> test_biota / test_live / test_ocean /
# test_weather / test_volcano / test_geography / test_ecoregions / test_civ.
# The CMake build also includes test_events for the viewer event journal.
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
people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural +
geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
on the Live World clock). **Step 1 of the roadmap is done:**
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
foundation everything civic references. `Planet::generateGeography()` extracts named features from
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
root (no two features share a base name); a **new volcanic island** is named on the fly when it
breaches (the island-formation event carries its name). Saved (**v17**). Knobs `geo*`.
*Next steps (not yet built): settlements + food/habitability, territory + borders, culture +
beliefs, conflict + diplomacy.*
- **Ecoregions (ecological provinces)** *(done — see `PlanetEcoregions.cpp`)*`generateEcoregions()`
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
@ -532,6 +549,8 @@ src/
PlanetFungiGen.cpp computeFungaDensity + fillFunga (moisture/organic-matter rule)
NameGen.* deterministic procedural name generator (syllable banks; reused by civ arc)
PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes)
PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2)
PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer)
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/PlanetBiota.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
```
(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 /
Weather / Volcano / Geography suites — same source list. CMake also builds `test_events` for the
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp` or `test_civ.cpp`
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.)
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) ·
`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) ·
`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 ·
`[`/`]` 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
@ -672,7 +694,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is
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
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
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**
@ -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
— 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
agents plus volcano state in step-back frames, v16 appends the saved **event journal**, and v17
appends the **geography/atlas** block — named features + per-cell region indices;
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, 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
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
@ -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
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
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
saved one.
**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 fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
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
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -34,6 +34,8 @@ set(SIM_SOURCES
src/sim/PlanetFungiGen.cpp
src/sim/NameGen.cpp
src/sim/PlanetGeography.cpp
src/sim/PlanetEcoregions.cpp
src/sim/PlanetCiv.cpp
src/sim/PlanetIO.cpp
)
@ -72,7 +74,7 @@ if(UNIX AND NOT APPLE)
endif()
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)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
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).
- `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc).
- `PlanetGeography.{hpp,cpp}``generateGeography()` (named features: continents/oceans/ranges/
rivers/lakes; the atlas, saved v17).
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.
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)
"which features is this cell in" — the hook the later territory/border step will build on. Geography is
static (terrain is frozen), so it is generated **once** on a settled world (key `M`, in or out of Live
World) and **saved (v17)** — names persist so a future culture step can rename places. The viewer draws
World) and **saved (v17+)** — names persist so a future culture step can rename places. The viewer draws
names as labels on the globe (the plate-label manual projection) + 2D map (minor features only when
zoomed, to declutter), lists them in an **Atlas** tab (5th live-info tab; click a row → `focusCell`),
and adds a "region" line to cell-info. Save v17 appends the feature records (with `std::string` names,
written field-by-field) + the POD index arrays; pre-v17 saves load with none and regenerate on demand.
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
mountains can't share a base name. New land created during Live World (a volcanic island breaching the
sea) is added to the atlas on the fly by `Planet::nameNewLand(cell)` — it joins an adjacent existing
landmass or mints a fresh unique Island name, which the island-formation `WorldEvent` then carries.
## 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
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::FaunaDensity: return "Fauna 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::TempWinter: return "Temperature (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 };
}
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.
Color tempColor(double celsius) {
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,
FloraDensity, FaunaDensity, FungaDensity,
Ecoregion, Habitability,
TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views
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.
Color marineFloraColor(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;
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).
if (sized(p.temperature()) && sized(p.moisture()))
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>& fauna = planet.faunaDensity();
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());
for (size_t i = 0; i < planet.cells.size(); ++i) {
switch (mode) {
@ -143,6 +146,20 @@ void Viewer::recolor() {
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;
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);
}
}
@ -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,
const std::vector<Volcano>& beforeVolcanoes) {
const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements) {
auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* {
for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws;
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)));
}
}
// 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) {
@ -396,6 +437,7 @@ void Viewer::saveGame(const char* path) {
os.write((char*)&f.w.rng, 4); os.write((char*)&f.w.nextId, 4);
wV(f.w.volcanoes);
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.
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
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 (!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
elapsedMy = em; settled = (st != 0);
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 (!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);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
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);
rV(f.w.volcanoes);
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(); };
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())
@ -601,17 +651,20 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
}
std::vector<WeatherSystem> beforeStorms;
std::vector<Volcano> beforeVolcanoes;
std::vector<Settlement> beforeSettlements;
if (dtWeather > 0.0) {
beforeStorms = planet.storms();
beforeVolcanoes = planet.volcanoes;
beforeSettlements = planet.settlements;
}
planet.stepWeather(dtWeather);
// Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// reasserts restored terrain/biome state without advancing the lifecycle.
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();
else if (vu.recolor) recolor();
else if (vu.recolor || cu.recolor) recolor();
rebuildLiveOverlay();
}

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- 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 EVENT_LOG_MAX = 200;
const char* CONFIG_PATH = "planet.cfg";
@ -100,7 +100,8 @@ struct Viewer {
bool showClouds = true; // Live World cloud/rain cover overlay (key K)
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V)
bool showNames = false; // geographic place-name labels (the atlas, key M)
std::vector<int> atlasRowCells; // cell to focus per visible Atlas-tab row (parallel to the list)
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.
struct WorldEvent {
@ -114,7 +115,7 @@ struct Viewer {
};
std::vector<WorldEvent> events;
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> eventRowRects;
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,
const std::string& title, const std::string& detail);
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 = "");
// ---- Input (ViewerInput.cpp) --------------------------------------------

View File

@ -44,7 +44,7 @@ void Viewer::handleInput() {
focusCell(events[ei].cell, events[ei].title);
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) {
if (!CheckCollisionPointRec(mp, eventRowRects[i])) continue;
if (atlasRowCells[i] >= 0) focusCell(atlasRowCells[i], "");
@ -185,10 +185,36 @@ void Viewer::handleInput() {
}
if (IsKeyPressed(KEY_L) && settled) { // generate / regenerate biota population
planet.generateBiota();
if (planet.ecoregionsBuilt()) planet.generateEcoregions();
if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity)
{ mode = ColorMode::FloraDensity; recolor(); }
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)
liveWorld = !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);
// Markers: selected (orange), hovered cell (yellow), hovered subcell (white).
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)});
}
}
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) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, 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});
int x = (int)r.x + 14, y = (int)r.y + 10;
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;
for (int i = 0; i < 5; ++i) {
float tw = (r.width - 20.0f) / 5.0f;
for (int i = 0; i < 7; ++i) {
float tw = (r.width - 20.0f) / 7.0f;
Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f };
liveInfoTabRects.push_back(tr);
bool on = liveInfoTab == i;
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});
int w = MeasureText(tabs[i], 14);
DrawText(tabs[i], (int)(tr.x + (tr.width - w) * 0.5f), (int)tr.y + 5, 14,
int tfs = 12; // smaller font: 7 tabs are narrow
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});
}
y = (int)r.y + 72;
@ -507,7 +549,7 @@ void Viewer::renderLiveInfo() {
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();
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});
@ -544,6 +586,68 @@ void Viewer::renderLiveInfo() {
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;
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]",
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",
showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off"));
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",
!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 | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
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();
renderLiveInfo();
renderPanels();

View File

@ -57,7 +57,11 @@ void Planet::buildGeometry() {
geoFeatures.clear(); sGeoRng = cfg.seed ? (cfg.seed ^ 0x6E0C12A7u) : 0x6E0C12A7u; sGeoSalt = 0;
sCellLand.assign(cells.size(), -1); sCellWater.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;
settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
sCellSettlement.assign(cells.size(), -1); sHabitability.clear();
}
void Planet::clearDerivedState() {

View File

@ -4,6 +4,8 @@
#include "PlanetTypes.hpp" // Cell, Plate, SubGrid/SubCell, PlanetConfig
#include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota
#include "PlanetGeography.hpp" // FeatureKind, GeoFeature
#include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate
#include "PlanetEcoregions.hpp" // Ecoregion
#include <vector>
#include <memory>
#include <cstdint>
@ -18,6 +20,8 @@ public:
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<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
// 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>& 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).
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
// 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:
// whether the stream carries the active reshuffle salt (save v18+). Older saves load with none
// (regenerated on demand), or with a deterministic first reshuffle if only the salt is absent.
// whether the stream carries the active reshuffle salt (save v18+); hasEcoregions: whether the
// 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 hasMoons = true, bool hasWeather = true, bool hasStorms = 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.
double cellWidthMeters() const; // approx lateral cell spacing
@ -277,6 +303,16 @@ private:
uint32_t sGeoRng = 1;
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
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.
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(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
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(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) \
I(geoOceanDeep) I(civMaxSettlements) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed)
@ -248,6 +251,18 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.geoMountainElev, 0.0, 12000.0, "geoMountainElev"));
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians"));
E(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.plateCount, 1, 100, "plateCount"));
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.geoMaxPeaks, 0, 100000, "geoMaxPeaks"));
E(irng(cfg.geoOceanDeep, 1, 1000, "geoOceanDeep"));
E(irng(cfg.civMaxSettlements, 0, 1000000, "civMaxSettlements"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
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, sCellRange); writeVec(os, sCellRiver);
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 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,
// 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 (!hasWeather) { hasStorms = false; hasVolcanoes = false; } // volcano block follows the weather block
if (!hasVolcanoes || !hasStatefulVolcanoes) hasGeography = false; // geography block follows the volcano 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
// 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);
}
}
// 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
return (bool)is;
}

View File

@ -95,6 +95,9 @@ struct WeatherSnapshot {
std::vector<Volcano> volcanoes;
uint32_t rng = 0, nextId = 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 {
@ -374,4 +377,22 @@ struct PlanetConfig {
double geoRiverMinDischarge = 80.0; // min mouth discharge for a named river
int geoMaxRivers = 40; // cap on named rivers (largest by discharge)
int geoMaxPeaks = 40; // cap on named peaks (highest)
// --- 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.storms = sStorms; s.rng = sWeatherRng; s.nextId = sStormNextId;
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;
}
@ -38,6 +40,8 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain;
sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId;
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();
}

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
//
// 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 "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;
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);
p.writeState(ss);
Planet r;
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());
if (match)
for (size_t i = 0; i < F.size(); ++i)