diff --git a/BUILD.md b/BUILD.md index e52fd5e..a942ef5 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 1c3c1fc..0848a6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 1–2 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a89c9c..cfbf3be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/docs/design-notes.md b/docs/design-notes.md index 10e727a..d577811 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -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` 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: diff --git a/src/render/Colors.cpp b/src/render/Colors.cpp index ecc46ce..fc71f94 100644 --- a/src/render/Colors.cpp +++ b/src/render/Colors.cpp @@ -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 diff --git a/src/render/Colors.hpp b/src/render/Colors.hpp index 5585eeb..bc6f446 100644 --- a/src/render/Colors.hpp +++ b/src/render/Colors.hpp @@ -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); diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index d9b5eb1..9a93a81 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -61,6 +61,42 @@ static std::vector 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%%", diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 660255a..fc8a2d9 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -119,6 +119,9 @@ void Viewer::recolor() { const std::vector& flora = planet.floraDensity(); const std::vector& fauna = planet.faunaDensity(); const std::vector& funga = planet.fungaDensity(); + const std::vector& ecoCell = planet.cellEcoregion(); + const auto& eco = planet.ecoregions(); + const std::vector& 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& beforeStorms, - const std::vector& beforeVolcanoes) { + const std::vector& beforeVolcanoes, + const std::vector& 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& 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(events.size(), (size_t)EVENT_LOG_MAX); @@ -429,7 +471,7 @@ void Viewer::loadGame(const char* path) { is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&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& 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& 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 beforeStorms; std::vector beforeVolcanoes; + std::vector 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(); } diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index d097ae5..193d684 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -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 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 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 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 liveInfoTabRects; std::vector eventRowRects; std::vector 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& beforeStorms, - const std::vector& beforeVolcanoes); + const std::vector& beforeVolcanoes, + const std::vector& beforeSettlements); void focusCell(int idx, const std::string& status = ""); // ---- Input (ViewerInput.cpp) -------------------------------------------- diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index 50dc3ec..8ca63c2 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -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) { diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index 08cbb44..19e5fc8 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -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 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 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(); diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index fab022c..3a8f5cd 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -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() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 609c8d2..ee4eab1 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -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 #include #include @@ -18,6 +20,8 @@ public: std::vector moons; // Live World: 1-3 natural satellites (generated + saved) std::vector volcanoes; // Live World: stateful lifecycle volcanoes (saved v15) std::vector geoFeatures; // named geographic features / the atlas (saved v17+) + std::vector ecoRegions; // named ecological provinces (saved v19+) + std::vector 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& cellRange() const { return sCellRange; } // mountain-range feature index (-1) const std::vector& 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& ecoregions() const { return ecoRegions; } + const std::vector& 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& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1) + const std::vector& 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 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 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 sCellSettlement; + std::vector 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 sFloraDensity, sFaunaDensity, sFungaDensity; diff --git a/src/sim/PlanetCiv.cpp b/src/sim/PlanetCiv.cpp new file mode 100644 index 0000000..4739bf8 --- /dev/null +++ b/src/sim/PlanetCiv.cpp @@ -0,0 +1,134 @@ +#include "Planet.hpp" +#include "NameGen.hpp" +#include +#include +#include + +// --- 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 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 usedNames; + std::vector 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 std::max(1.0, st.population * 0.0005)) up.recolor = true; + st.population = P; + } + return up; +} diff --git a/src/sim/PlanetCiv.hpp b/src/sim/PlanetCiv.hpp new file mode 100644 index 0000000..29e8f6c --- /dev/null +++ b/src/sim/PlanetCiv.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include + +// 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" diff --git a/src/sim/PlanetEcoregions.cpp b/src/sim/PlanetEcoregions.cpp new file mode 100644 index 0000000..3921441 --- /dev/null +++ b/src/sim/PlanetEcoregions.cpp @@ -0,0 +1,208 @@ +#include "Planet.hpp" +#include "NameGen.hpp" +#include +#include +#include +#include +#include + +// --- 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> comps; + std::vector seen(n, 0); + std::vector stack; + for (int i = 0; i < n; ++i) { + if (seen[i] || cells[i].biome == Biome::Ice) continue; + std::vector 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& 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& comp, BiotaKind kind) { + const auto& AR = biotaArchetypes(); + std::vector score(AR.size(), 0.0); + if (sHasBiota && sBiota.size() == cells.size()) { + for (int i : comp) { + const std::vector* 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& arr = (context == EcoOcean) ? sCellWater : sCellLand; + if (anchor >= 0 && anchor < (int)arr.size()) return arr[anchor]; + return -1; + }; + auto updateProductivity = [&](Ecoregion& e, const std::vector& 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 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; + } +} diff --git a/src/sim/PlanetEcoregions.hpp b/src/sim/PlanetEcoregions.hpp new file mode 100644 index 0000000..994f0a8 --- /dev/null +++ b/src/sim/PlanetEcoregions.hpp @@ -0,0 +1,22 @@ +#pragma once +#include "PlanetTypes.hpp" // Biome +#include +#include + +// 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; +}; diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 362638f..3d69048 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -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; } diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index c6702ac..cd4d186 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -95,6 +95,9 @@ struct WeatherSnapshot { std::vector 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 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 }; diff --git a/src/sim/PlanetWeather.cpp b/src/sim/PlanetWeather.cpp index 5108b59..9997d0d 100644 --- a/src/sim/PlanetWeather.cpp +++ b/src/sim/PlanetWeather.cpp @@ -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(); } diff --git a/test_civ.cpp b/test_civ.cpp new file mode 100644 index 0000000..49bb726 --- /dev/null +++ b/test_civ.cpp @@ -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 +#include +#include +#include +#include + +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 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; +} diff --git a/test_ecoregions.cpp b/test_ecoregions.cpp new file mode 100644 index 0000000..016ec40 --- /dev/null +++ b/test_ecoregions.cpp @@ -0,0 +1,131 @@ +// Headless test for the ecoregion atlas. No display needed. + +#include "Planet.hpp" +#include +#include +#include +#include +#include + +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& a, const std::vector& b) { + if (a.size() != b.size()) return false; + auto eq = [](const std::vector& x, const std::vector& 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 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; +} diff --git a/test_geography.cpp b/test_geography.cpp index 6be70b0..c7912d2 100644 --- a/test_geography.cpp +++ b/test_geography.cpp @@ -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)