From 91f1b90a93c9d38b65b442b71359990fb24cb3e0 Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Wed, 1 Jul 2026 07:58:58 +0200 Subject: [PATCH] Civ Step 4: culture, beliefs & governments Give the world peoples and faiths on top of Step 3's realms. All derived deterministically from the (saved) settlement set + geography (pure hashes, no RNG, recomputed each sim year -> no save-version bump, step-back free). - PlanetCulture.{hpp,cpp}: computeCultures() groups living settlements into one culture per inhabited continent, each with a generated people name, a dominant ethos picked from its cells' environment (Seafaring/Highland/Nomadic/Agrarian, else a hashed Mercantile/Warlike), and a religion (a Faith focus biased by the dominant biome + a generated faith name). Runs after computeTerritory(). - Governments: each realm (Nation) gets a GovType from its tier + a deterministic pick, folded into its name ("Republic of X", "Duchy of X", "X Theocracy", "X Confederation", "X Dominion", ...) -- culture-driven renaming. - Culture-driven grouping: realm vassalage is now restricted to the same continent, so every kingdom/empire is mono-cultural. - Render: a Culture colour mode (cultureColor, cultural blocs) + pale buildCultureBorders lines + a Cultures tab + a cell-info culture/faith line + government-aware realm labels, all under key X. Cultures recompute with territory in rebuildTerritory(). - test_culture.cpp: one culture per continent, valid ethos/faith, governments per tier reflected in names, mono-cultural realms, determinism, RNG isolation, save->load->recompute parity. All 12 suites green. Co-Authored-By: Claude Opus 4.8 --- BUILD.md | 11 ++- CLAUDE.md | 36 +++++-- CMakeLists.txt | 3 +- docs/design-notes.md | 32 +++++- src/render/Colors.cpp | 7 ++ src/render/Colors.hpp | 4 +- src/render/Overlays.cpp | 14 ++- src/render/Overlays.hpp | 2 + src/render/Panels.cpp | 10 ++ src/render/Viewer.cpp | 16 ++- src/render/Viewer.hpp | 2 + src/render/ViewerInput.cpp | 10 ++ src/render/ViewerRender.cpp | 51 ++++++++-- src/sim/Planet.cpp | 1 + src/sim/Planet.hpp | 17 +++- src/sim/PlanetCulture.cpp | 191 ++++++++++++++++++++++++++++++++++++ src/sim/PlanetCulture.hpp | 29 ++++++ src/sim/PlanetNation.cpp | 1 + src/sim/PlanetNation.hpp | 19 ++-- test_culture.cpp | 184 ++++++++++++++++++++++++++++++++++ 20 files changed, 611 insertions(+), 29 deletions(-) create mode 100644 src/sim/PlanetCulture.cpp create mode 100644 src/sim/PlanetCulture.hpp create mode 100644 test_culture.cpp diff --git a/BUILD.md b/BUILD.md index 478fe0b..840f8ad 100644 --- a/BUILD.md +++ b/BUILD.md @@ -58,6 +58,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain: I habitability heat map (where civilization can thrive) U settlements: the dawn of civilization on first press, then toggle markers (Civ tab) P territory / realms view + political borders (nations listed in the Realms tab) + X culture / faiths view + cultural borders (peoples/ethos/religion in the Cultures 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) @@ -323,6 +324,12 @@ territory, leaving wilderness frontiers between realms. civEmpireMinMembers 5 settlements in a realm to count as an empire civEmpirePop 5e6 total realm population to count as an empire +Culture, beliefs & governments (key X — derived from settlements + geography, not saved): settlements +on a continent share a culture (a people with an environment-driven ethos + a religion); each realm +gets a government type folded into its name (Republic of X / Duchy of X / X Theocracy / ...); realms +stay mono-cultural. No config knobs — the ethos/faith/government rules are constants in +src/sim/PlanetCulture.cpp (computeCultures). + ## Headless logic test (no display) g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ @@ -333,11 +340,11 @@ territory, leaving wilderness frontiers between realms. 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/PlanetNation.cpp \ + src/sim/PlanetCiv.cpp src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t # 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 / test_nation. + # test_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation / test_culture. # 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 063a93c..638c445 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). **Steps 1–3 of the roadmap are done (plus a derived ecoregions atlas):** +on the Live World clock). **Steps 1–4 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), @@ -166,7 +166,25 @@ on the Live World clock). **Steps 1–3 of the roadmap are done (plus a derived **pure function of the (saved) settlements**, so they're **recomputed** (each sim year / on placement / load / step-back) — **no save state, no version bump**, peaceful & population-driven. Knobs `civTerritory*`/ `civVassalRange`/`civEmpire*`. - *Next steps (not yet built): culture + beliefs + governments (and culture-driven borders/renaming), +- **Culture, beliefs & governments (Step 4)** *(done — see `PlanetCulture.cpp`)* — settlements on the + same continent share a **culture** (a people/language family). `computeCultures()` (deterministic, no + RNG, runs right after `computeTerritory()`) groups living settlements by continent (`Settlement.regionId`) + into **one culture per inhabited continent**, each with: a generated **people name** ("the Velmar", from + the continent's `NameGen` bank), a dominant **ethos** picked from the aggregate environment of its cells + (coastal→**Seafaring**, mountains/hills→**Highland**, desert/dry→**Nomadic**, fertile→**Agrarian**, else a + hash-picked **Mercantile/Warlike**), and a **religion** — a `Faith` focus biased by the dominant biome + (coast→Sea, mountains→Sky, desert→Sun, cold→Ancestors, forest→Harvest…) plus a generated **faith name** + ("the Tidewardens"). Each **realm** (Step-3 `Nation`) also gets a **government** (`GovType`) from its tier + + a deterministic pick (City-state→Republic/Chiefdom/Theocracy, Kingdom→Kingdom/Duchy/Theocracy, Empire→ + Empire/Autocracy/Confederation), folded into its **name** ("Republic of X", "Duchy of X", "X Theocracy", + "X Confederation", "X Dominion"…) — **culture-driven renaming**. **Culture-driven grouping**: realm + vassalage is now restricted to the **same continent**, so every kingdom/empire is **mono-cultural**. + Render: a **Culture** colour mode (`cultureColor`, cultural blocs over the political map) + pale + **cultural-region borders** + a **Cultures** tab (key **`X`**), a cell-info culture/faith line, and the + government-aware realm labels. Like territory, cultures are a **pure function of the (saved) settlements + + geography**, so they're **recomputed** each sim year / on placement / load / step-back — **no save state, + no version bump**. Knobs: none (ethos/faith/government rules are internal constants). + *Next steps (not yet built): cultural **evolution** (spread along borders, drift, assimilation, schism); conflict + diplomacy (war moving borders by force), trade.* ## Current state @@ -578,6 +596,7 @@ src/ PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity) PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2) PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3) + PlanetCulture.* computeCultures (cultures/ethos/faith per continent + governments; civ Step 4) PlanetIO.cpp config file (text) + binary save/load render/ (raylib viewer) Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota) @@ -641,13 +660,13 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.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/PlanetNation.cpp \ + src/sim/PlanetNation.cpp src/sim/PlanetCulture.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`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp` or -`test_nation.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / -Civilization / Nation suites — same source list. CMake also builds `test_events` for the +`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`, +`test_nation.cpp` or `test_culture.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / +Civilization / Nation / Culture 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 @@ -684,6 +703,7 @@ all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured co `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) · `P` territory / realms view + political borders (Realms tab lists nations) · +`X` culture / faiths view + cultural borders (Cultures tab lists peoples, ethos & religion) · `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 @@ -904,6 +924,10 @@ triangles (plates are fixed in phase 1). threshold `civEmpireMinMembers` (5 settlements) / `civEmpirePop` (5e6 total). Territory + realms are **derived** (recomputed each sim year, not saved). Realm colours/border colour/labels are render constants (Colors.cpp / ViewerRender.cpp). +- **Culture, beliefs & governments (civ Step 4, key `X`):** **no config knobs** — the ethos/faith/ + government rules are internal constants in `PlanetCulture.cpp` (ethos-signal threshold 0.34, the + biome→faith map, the tier→government picks) and the culture colours/border colour are render constants. + Cultures are derived (recomputed with territory, not saved). To retune, edit `computeCultures()`. - `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 e6b2caa..913854a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,7 @@ set(SIM_SOURCES src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp src/sim/PlanetNation.cpp + src/sim/PlanetCulture.cpp src/sim/PlanetIO.cpp ) @@ -75,7 +76,7 @@ if(UNIX AND NOT APPLE) endif() enable_testing() -foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation) +foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture) 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 7c7418f..84925db 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -53,6 +53,8 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`). - `PlanetCiv.{hpp,cpp}` — `computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2: habitability + settlements that grow/decline on the live clock; saved v20). - `PlanetNation.{hpp,cpp}` — `computeTerritory` (civ Step 3: realms + per-cell ownership; derived, not saved). +- `PlanetCulture.{hpp,cpp}` — `computeCultures` (civ Step 4: cultures/ethos/faith per continent + + government per realm; derived, not saved). - `PlanetIO.cpp` — text config + binary save/load. The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration), @@ -420,6 +422,31 @@ across a recompute by capital id). The viewer recomputes territory + rebuilds bo year** (`liveAdvance` year-tick), and on placement / load / step-back; `buildGeometry()` clears `nations`/`sCellNation`/`sSettleNation` on reseed. Knobs `civTerritory*`/`civVassal*`/`civEmpire*`. +## Civilization Step 4 — culture, beliefs & governments + +`PlanetCulture.cpp` (engine, raylib-free, **no RNG** → tectonic stream untouched). `computeCultures()` +runs right after `computeTerritory()` (it reads `nations` / `sCellNation`) and is a **pure deterministic +function** of the settlement set + geography + biomes — so it is *recomputed*, never saved (no +`SAVE_VERSION` bump), and step-back replays it for free. (1) **One culture per inhabited continent**: +living settlements are grouped by `Settlement.regionId` (its continent geography-feature; fall back to +the language `bank` when there's no geography). Each `Culture` gets a **people name** (`namegen::makeName` +from the continent's bank), a dominant **ethos** = argmax of environmental fractions over its settlement +cells (coastal→Seafaring, mountains/hills→Highland, desert/dry→Nomadic, fertile-biome→Agrarian; if none +≥ 0.34, a `cultHash` pick of Mercantile/Warlike/Agrarian), and a **religion** — a `Faith` focus from the +dominant biome (coast→Sea, mountains→Sky, desert→Sun, cold→Ancestors, forest/wetland→Harvest, with a +rare hash Moon/War) plus a generated `faithName`. (2) **Government per realm**: each `Nation` gets a +`GovType` from its tier + a `cultHash` pick and its `name` is **rewritten** to fold it in ("Republic of +X", "Duchy of X", "X Theocracy", "X Confederation", "X Dominion"…); `nation.cultureId` = its capital's +culture. (3) **Per-cell culture** `sCellCulture[c]` = the culture of `sCellNation[c]`'s nation (reuses +territory's ownership) → the culture view shows **cultural blocs** over the political map. + +**Culture-driven grouping**: `computeTerritory()`'s realm-join test now requires the vassal and its +candidate capital to share a `regionId`, so every realm is **mono-cultural** (no kingdom spans two +continents). Render: a `Culture` colour mode (`cultureColor`) + pale `buildCultureBorders` lines + a +`Cultures` tab (9th) + a cell-info culture/faith line + government-aware realm labels, all under key `X`; +`rebuildTerritory()` computes territory **then** cultures and rebuilds both border sets each sim year / +placement / load / step-back. No config knobs (rules are constants in `computeCultures()`). + ## Headless testing Engine is raylib-free, so logic is tested without a display. Build/run: @@ -431,8 +458,11 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet 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/PlanetNation.cpp \ + src/sim/PlanetCulture.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t -# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography use the same list. +# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography / test_ecoregions / +# test_civ / test_nation / test_culture use the same source list. ``` (add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism). diff --git a/src/render/Colors.cpp b/src/render/Colors.cpp index b0e9451..aac75e0 100644 --- a/src/render/Colors.cpp +++ b/src/render/Colors.cpp @@ -94,6 +94,7 @@ const char* colorModeName(ColorMode m) { case ColorMode::Ecoregion: return "Ecoregions"; case ColorMode::Habitability: return "Habitability"; case ColorMode::Territory: return "Territory / realms"; + case ColorMode::Culture: return "Culture / faiths"; case ColorMode::TempSummer: return "Temperature (summer)"; case ColorMode::TempWinter: return "Temperature (winter)"; case ColorMode::Seasonality: return "Seasonality (summer-winter)"; @@ -149,6 +150,12 @@ Color nationColor(int id) { // distinct per-realm tint (offset hue/s return ColorFromHSV(h, 0.58f, 0.92f); } +Color cultureColor(int id) { // per-culture tint (different phase so blocs read vs realms) + if (id < 0) return Color{ 40, 44, 50, 255 }; // no culture: dim grey + float h = std::fmod((id + 2) * 0.61803398875f + 0.47f, 1.0f) * 360.0f; + return ColorFromHSV(h, 0.50f, 0.86f); // slightly softer/paler than realm tints +} + 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] = { diff --git a/src/render/Colors.hpp b/src/render/Colors.hpp index 085b421..a047888 100644 --- a/src/render/Colors.hpp +++ b/src/render/Colors.hpp @@ -6,7 +6,7 @@ enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip, FloraDensity, FaunaDensity, FungaDensity, - Ecoregion, Habitability, Territory, + Ecoregion, Habitability, Territory, Culture, TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views Color elevationColor(double e, double seaLevel); @@ -44,3 +44,5 @@ Color ecoregionColor(int id, Biome b, double productivity); Color habitabilityColor(double h01); // Per-nation territory tint (golden-ratio HSV, offset from plateColor so realms read distinctly). Color nationColor(int id); +// Per-culture tint (golden-ratio HSV, a different phase than nationColor so cultural blocs read distinctly). +Color cultureColor(int id); diff --git a/src/render/Overlays.cpp b/src/render/Overlays.cpp index cc37857..22b6972 100644 --- a/src/render/Overlays.cpp +++ b/src/render/Overlays.cpp @@ -41,9 +41,11 @@ void buildBorders(const Planet& p, float radius, } } -void buildNationBorders(const Planet& p, float radius, std::vector& segs) { +// Trace boundaries where a per-cell integer label differs across a triangle's cells (the plate +// dual-contour, parameterised by the label array). Shared by nation + culture borders. +static void buildLabelBorders(const Planet& p, float radius, const std::vector& cn, + std::vector& segs) { segs.clear(); - const std::vector& cn = p.cellNation(); if ((int)cn.size() != (int)p.cells.size()) return; auto midV = [&](int i, int j) -> Vector3 { Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius; @@ -69,6 +71,14 @@ void buildNationBorders(const Planet& p, float radius, std::vector& seg } } +void buildNationBorders(const Planet& p, float radius, std::vector& segs) { + buildLabelBorders(p, radius, p.cellNation(), segs); +} + +void buildCultureBorders(const Planet& p, float radius, std::vector& segs) { + buildLabelBorders(p, radius, p.cellCulture(), segs); +} + void buildDriftArrows(const Planet& p, float radius, std::vector& out, std::vector& labels) { out.clear(); labels.clear(); diff --git a/src/render/Overlays.hpp b/src/render/Overlays.hpp index f002504..bd585da 100644 --- a/src/render/Overlays.hpp +++ b/src/render/Overlays.hpp @@ -18,6 +18,8 @@ void buildBorders(const Planet& p, float radius, // Same dual-contour, but separating cells of different `cellNation()` (a realm's outline: // inter-realm borders + its coast + wilderness frontier). One segment list. void buildNationBorders(const Planet& p, float radius, std::vector& segs); +// Like buildNationBorders but on the per-cell culture split (civ Step 4): cultural-region outlines. +void buildCultureBorders(const Planet& p, float radius, std::vector& segs); // ---- Per-plate drift arrows ------------------------------------------------- struct PlateLabel { int id; Vector3 pos; }; diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index d1141e8..a248b48 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -116,6 +116,16 @@ static std::vector cellInfo(const Planet& p, int i, double elev, do L.push_back(std::string("realm: wilderness")); } } + // Culture, ethos & faith of this cell's people (civ Step 4). + if (p.culturesBuilt()) { + const auto& cc = p.cellCulture(); + int ci = (i < (int)cc.size()) ? cc[i] : -1; + if (ci >= 0 && ci < (int)p.cultureList().size()) { + const Culture& cu = p.cultureList()[ci]; + L.push_back(std::string("culture: ") + cu.name + " (" + cultureEthosName(cu.ethos) + ")"); + L.push_back(std::string("faith: ") + cu.faithName + " (" + faithFocusName(cu.faith) + ")"); + } + } // 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 ecbf13f..f5ec125 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -123,6 +123,7 @@ void Viewer::recolor() { const auto& eco = planet.ecoregions(); const std::vector& hab = planet.habitability(); const std::vector& cnat = planet.cellNation(); + const std::vector& ccult = planet.cellCulture(); vcolors.resize(planet.cells.size()); for (size_t i = 0; i < planet.cells.size(); ++i) { switch (mode) { @@ -168,6 +169,13 @@ void Viewer::recolor() { ? Color{60, 64, 58, 255} : Color{26, 34, 52, 255}; break; } + case ColorMode::Culture: { + int ci = (i < (int)ccult.size()) ? ccult[i] : -1; + if (ci >= 0) vcolors[i] = cultureColor(ci); // owned: culture tint + else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // uncultured land vs sea + ? Color{60, 64, 58, 255} : Color{26, 34, 52, 255}; + break; + } default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel); } } @@ -728,14 +736,18 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { } } if (vu.breach) refreshView(); - else if (vu.recolor || cu.recolor || (territoryChanged && mode == ColorMode::Territory)) recolor(); + else if (vu.recolor || cu.recolor + || (territoryChanged && (mode == ColorMode::Territory || mode == ColorMode::Culture))) recolor(); rebuildLiveOverlay(); } -// Recompute realms/territory from the (derived) settlement set + rebuild the nation-border segments. +// Recompute realms/territory + cultures from the (derived) settlement set and rebuild the border +// segments (political + cultural). Cultures depend on territory, so compute them right after. void Viewer::rebuildTerritory() { planet.computeTerritory(); + planet.computeCultures(); buildNationBorders(planet, borderR, nationBorders); + buildCultureBorders(planet, borderR, cultureBorders); double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays); lastTerritoryYear = (long)std::floor(liveTime / yearHours); } diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 7997fb9..546b25a 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -104,6 +104,8 @@ struct Viewer { std::vector atlasRowCells; // cell to focus per visible Atlas/Eco/Civ/Realms-tab row (parallel to the list) std::vector nationBorders; // political border segments (rebuilt on year tick / placement / load) bool showNationBorders = false; // draw nation/realm borders (on with the Territory view) + std::vector cultureBorders; // cultural-region border segments (civ Step 4, rebuilt with territory) + bool showCultureBorders = false; // draw cultural-region borders (on with the Culture view) long lastTerritoryYear = -1; // sim year territory was last recomputed (recompute when it ticks) // World event journal: currently Live World events, shaped to be reused by later phases. diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index 8c37da4..8c688ab 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -225,6 +225,16 @@ void Viewer::handleInput() { setStatus(mode == ColorMode::Territory ? "Territory / realms on" : "Territory off"); } } + if (IsKeyPressed(KEY_X) && settled) { // toggle the culture / faiths colour view + cultural borders + if (!planet.settlementsPlaced()) setStatus("Press U for the dawn of civilization first"); + else { + if (!planet.culturesBuilt() || (int)planet.cellCulture().size() != (int)planet.cells.size()) rebuildTerritory(); + mode = (mode == ColorMode::Culture) ? ColorMode::Biome : ColorMode::Culture; + showCultureBorders = (mode == ColorMode::Culture); + recolor(); + setStatus(mode == ColorMode::Culture ? "Cultures / faiths on" : "Cultures 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 784645c..430cf21 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -90,6 +90,14 @@ void Viewer::renderGlobe3D() { } rlEnd(); rlSetLineWidth(1.0f); } + if (showCultureBorders && !cultureBorders.empty()) { // cultural-region borders (pale, distinct from political) + rlSetLineWidth(3.0f); rlBegin(RL_LINES); rlColor4ub(245, 240, 220, 220); + for (size_t i = 0; i + 1 < cultureBorders.size(); i += 2) { + rlVertex3f(cultureBorders[i].x, cultureBorders[i].y, cultureBorders[i].z); + rlVertex3f(cultureBorders[i + 1].x, cultureBorders[i + 1].y, cultureBorders[i + 1].z); + } + rlEnd(); rlSetLineWidth(1.0f); + } if (showDrift && !driftArrows.empty()) { rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); for (size_t i = 0; i + 1 < driftArrows.size(); i += 2) { @@ -344,6 +352,7 @@ void Viewer::renderMap2D() { if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f, vr, mapLon); if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f, vr, mapLon); if (showNationBorders && !nationBorders.empty()) drawSegments2D(nationBorders, Color{18, 18, 26, 235}, 2.0f, vr, mapLon); + if (showCultureBorders && !cultureBorders.empty()) drawSegments2D(cultureBorders, Color{245, 240, 220, 230}, 2.5f, vr, mapLon); if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, vr, mapLon); if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, vr, mapLon); if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, vr, mapLon); @@ -443,16 +452,16 @@ 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[8] = { "Sky", "Tides", "Weather", "Events", "Atlas", "Eco", "Civ", "Realms" }; + const char* tabs[9] = { "Sky", "Tides", "Weather", "Events", "Atlas", "Eco", "Civ", "Realms", "Culture" }; float tx = r.x + 10.0f, ty = r.y + 38.0f; - for (int i = 0; i < 8; ++i) { - float tw = (r.width - 20.0f) / 8.0f; + for (int i = 0; i < 9; ++i) { + float tw = (r.width - 20.0f) / 9.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 tfs = 12; // smaller font: 7 tabs are narrow + int tfs = 11; // smaller font: 9 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}); @@ -674,7 +683,7 @@ void Viewer::renderLiveInfo() { y += 20; } } - } else { // Realms: nations by population (largest first); click a row to fly to the capital + } else if (liveInfoTab == 7) { // Realms: nations by population (largest first); click a row to fly to the capital const auto& N = planet.nationList(); DrawText("Realms", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d", (int)N.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255}); @@ -702,6 +711,34 @@ void Viewer::renderLiveInfo() { y += 20; } } + } else { // Cultures: peoples by population (largest first); click a row to fly to their largest city + const auto& C = planet.cultureList(); + DrawText("Cultures", x, y, 18, Color{200, 205, 220, 255}); + DrawText(TextFormat("%d", (int)C.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255}); + y += 26; + if (C.empty()) { + DrawText(planet.settlementsPlaced() ? "press X for the culture view" : "press U then X", x, y, 13, Color{150, 155, 170, 255}); + } else { + const auto& sc = planet.settleCulture(); + std::vector idx(C.size()); + for (size_t i = 0; i < C.size(); ++i) idx[i] = (int)i; + std::sort(idx.begin(), idx.end(), [&](int a, int b) { return C[a].totalPop > C[b].totalPop; }); + for (int ci : idx) { + if (y > (int)(r.y + r.height) - 22) break; + const Culture& cu = C[ci]; + // Representative cell: the largest living settlement of this culture (to fly to). + int repCell = -1; double repPop = -1.0; + for (size_t s = 0; s < planet.settlements.size(); ++s) + if (s < sc.size() && sc[s] == ci && planet.settlements[s].population > repPop) + { repPop = planet.settlements[s].population; repCell = planet.settlements[s].cell; } + Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f }; + eventRowRects.push_back(row); atlasRowCells.push_back(repCell); + DrawText(cu.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, cultureColor(ci)); + DrawText(TextFormat("%s %s", cultureEthosName(cu.ethos), faithFocusName(cu.faith)), + (int)(r.x + r.width) - 118, (int)row.y + 3, 11, Color{150, 158, 178, 255}); + y += 20; + } + } } } @@ -780,10 +817,10 @@ void Viewer::renderHUD() { 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] | E eco | I habitability | U settlements [%s] | P territory [%s]", + line(TextFormat("K clouds [%s] | V volcanoes [%s] | M names [%s] | E eco | I habitability | U settlements [%s] | P territory [%s] | X culture [%s]", showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off", !planet.settlementsPlaced() ? "seed" : showSettlements ? "on" : "off", - showNationBorders ? "on" : "off")); + showNationBorders ? "on" : "off", showCultureBorders ? "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"); diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index 745ab1a..91cef33 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -64,6 +64,7 @@ void Planet::buildGeometry() { sCellSettlement.assign(cells.size(), -1); sHabitability.clear(); sCivCond.clear(); sCivDrought.clear(); nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear(); + cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); } void Planet::clearDerivedState() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 20a6d51..f2e54e6 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -5,7 +5,8 @@ #include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota #include "PlanetGeography.hpp" // FeatureKind, GeoFeature #include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate -#include "PlanetNation.hpp" // Nation, NationTier +#include "PlanetNation.hpp" // Nation, NationTier, GovType +#include "PlanetCulture.hpp" // Culture, CultureEthos, Faith #include "PlanetEcoregions.hpp" // Ecoregion #include #include @@ -24,6 +25,7 @@ public: std::vector ecoRegions; // named ecological provinces (saved v19+) std::vector settlements; // civilization: settlements placed once, grow/decline (saved v20+) std::vector nations; // realms grouped from settlements (derived each computeTerritory, not saved) + std::vector cultures; // peoples/cultures, one per inhabited continent (derived, not saved) // Phase flag: false during Phase-1 forming (modest, original tectonics that // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts @@ -199,6 +201,16 @@ public: const std::vector& nationList() const { return nations; } const std::vector& cellNation() const { return sCellNation; } // nation index per cell (-1 = wilderness/sea) const std::vector& settleNation() const { return sSettleNation; } // nation index per settlement (-1 = dead) + + // Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() groups settlements into + // peoples (one per inhabited continent), gives each a dominant ethos + religion, assigns each realm + // a government type (folded into nation.name), and tags cells/settlements with their culture. Runs + // AFTER computeTerritory() (reads nations/sCellNation). Purely derived, recomputed, not saved. + void computeCultures(); + bool culturesBuilt() const { return !cultures.empty(); } + const std::vector& cultureList() const { return cultures; } + const std::vector& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none) + const std::vector& settleCulture() const { return sSettleCulture; } // culture index per settlement (-1 = none) // Per-settlement live conditions (derived each stepCivilization; not saved). condition = the combined // environmental multiplier on carrying capacity (1 = normal, <1 = hardship, >1 = boom); drought = // current drought severity 0..1. Parallel to `settlements`. Used by the viewer for tint + events. @@ -333,6 +345,9 @@ private: // Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell // (-1 = wilderness/ocean); sSettleNation: nation index per settlement. std::vector sCellNation, sSettleNation; + // Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell + // (-1 = none/wilderness/ocean); sSettleCulture: culture index per settlement. + std::vector sCellCulture, sSettleCulture; // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // on-demand discrete population (saved). sHasBiota latches once generated/loaded. diff --git a/src/sim/PlanetCulture.cpp b/src/sim/PlanetCulture.cpp new file mode 100644 index 0000000..4dd5de8 --- /dev/null +++ b/src/sim/PlanetCulture.cpp @@ -0,0 +1,191 @@ +#include "Planet.hpp" +#include "NameGen.hpp" +#include +#include +#include + +// --- Civilization Step 4: cultures, beliefs & governments -------------------- +// Settlements on the same continent share a CULTURE (a people/language family with an environment-driven +// ethos + a religion); each realm gets a GOVERNMENT type folded into its name. A pure deterministic +// function of the settlement set + geography + biomes (no RNG -> tectonic stream untouched), computed +// AFTER computeTerritory() so `nations` / `sCellNation` already exist. Recomputed rather than saved. + +namespace { + // A pure hash for deterministic picks -- never touches rngState (mirrors civHash in PlanetCiv.cpp). + inline uint32_t cultHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; } +} + +const char* cultureEthosName(CultureEthos e) { + switch (e) { + case CultureEthos::Agrarian: return "Agrarian"; + case CultureEthos::Seafaring: return "Seafaring"; + case CultureEthos::Nomadic: return "Nomadic"; + case CultureEthos::Highland: return "Highland"; + case CultureEthos::Mercantile: return "Mercantile"; + case CultureEthos::Warlike: return "Warlike"; + } + return "Agrarian"; +} + +const char* faithFocusName(Faith f) { + switch (f) { + case Faith::Sun: return "Sun"; + case Faith::Moon: return "Moon"; + case Faith::Sea: return "Sea"; + case Faith::Sky: return "Sky"; + case Faith::Earth: return "Earth"; + case Faith::Ancestors: return "Ancestors"; + case Faith::War: return "War"; + case Faith::Harvest: return "Harvest"; + } + return "Earth"; +} + +const char* govTypeName(GovType g) { + switch (g) { + case GovType::Tribe: return "Chiefdom"; + case GovType::CityRepublic: return "Republic"; + case GovType::Duchy: return "Duchy"; + case GovType::Kingdom: return "Kingdom"; + case GovType::Theocracy: return "Theocracy"; + case GovType::Confederation: return "Confederation"; + case GovType::Empire: return "Empire"; + case GovType::Autocracy: return "Autocracy"; + } + return "Kingdom"; +} + +void Planet::computeCultures() { + const int n = (int)cells.size(); + cultures.clear(); + sCellCulture.assign(n, -1); + sSettleCulture.assign(settlements.size(), -1); + if (settlements.empty()) return; + + const double sea = cfg.seaLevel, abP = cfg.civAbandonPop; + const uint32_t seed = cfg.seed ? cfg.seed : 1u; + + // 1) Group living settlements into cultures, one per inhabited continent (regionId). Settlements + // with no geography (regionId < 0) fall back to a per-language-bank culture. + std::unordered_map keyToCulture; + auto living = [&](const Settlement& s) { + return s.cell >= 0 && s.cell < n && s.population >= abP; + }; + for (size_t k = 0; k < settlements.size(); ++k) { + const Settlement& s = settlements[k]; + if (!living(s)) continue; + int key = (s.regionId >= 0) ? s.regionId : (-1 - s.bank); + auto it = keyToCulture.find(key); + int ci; + if (it == keyToCulture.end()) { + ci = (int)cultures.size(); + Culture cu; cu.id = (uint32_t)ci + 1; cu.regionId = s.regionId; cu.bank = s.bank; + cultures.push_back(cu); + keyToCulture[key] = ci; + } else ci = it->second; + sSettleCulture[k] = ci; + cultures[ci].members++; + cultures[ci].totalPop += s.population; + } + if (cultures.empty()) return; + + // 2) Environmental tally over each culture's settlement cells -> ethos + a dominant biome for faith. + struct Acc { int cnt = 0, coast = 0, high = 0, arid = 0, fert = 0; int biome[13] = {0}; }; + std::vector acc(cultures.size()); + const bool haveMoist = (int)sMoist.size() == n; + for (size_t k = 0; k < settlements.size(); ++k) { + int ci = sSettleCulture[k]; if (ci < 0) continue; + int c = settlements[k].cell; + Acc& a = acc[ci]; a.cnt++; + bool coast = false; + for (int j : cells[c].neighbors) if (cells[j].elevation <= sea) { coast = true; break; } + if (coast) a.coast++; + Biome b = cells[c].biome; + if ((int)b >= 0 && (int)b < 13) a.biome[(int)b]++; + if (b == Biome::Mountains || b == Biome::Hills) a.high++; + if (b == Biome::Desert || (haveMoist && sMoist[c] < 0.25)) a.arid++; + if (b == Biome::Grassland || b == Biome::Forest || b == Biome::Wetland || b == Biome::Savanna) a.fert++; + } + + // 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed). + for (size_t ci = 0; ci < cultures.size(); ++ci) { + Culture& cu = cultures[ci]; + const Acc& a = acc[ci]; + double cnt = std::max(1, a.cnt); + double fCoast = a.coast / cnt, fHigh = a.high / cnt, fArid = a.arid / cnt, fFert = a.fert / cnt; + uint32_t rk = (uint32_t)(cu.regionId >= 0 ? cu.regionId : (1000 - cu.bank)) + 1u; + + // Ethos: the dominant environmental signal; if none is strong, a deterministic social pick. + CultureEthos best = CultureEthos::Agrarian; double bestF = fFert; + if (fCoast > bestF) { bestF = fCoast; best = CultureEthos::Seafaring; } + if (fHigh > bestF) { bestF = fHigh; best = CultureEthos::Highland; } + if (fArid > bestF) { bestF = fArid; best = CultureEthos::Nomadic; } + if (bestF >= 0.34) cu.ethos = best; + else { + uint32_t h = cultHash(seed ^ (rk * 2654435761u) ^ 0xC0FFEEu); + cu.ethos = (cu.members >= 3 && h % 3u == 0u) ? CultureEthos::Mercantile + : (h % 2u ? CultureEthos::Warlike : CultureEthos::Agrarian); + } + + // Faith focus: coastal peoples worship the Sea, else the dominant biome sets it, with a rare + // deterministic Moon/War variation. + Faith f; + if (fCoast >= 0.5) f = Faith::Sea; + else { + int dom = 0; for (int bi = 1; bi < 13; ++bi) if (a.biome[bi] > a.biome[dom]) dom = bi; + switch ((Biome)dom) { + case Biome::Mountains: case Biome::Hills: f = Faith::Sky; break; + case Biome::Desert: case Biome::Savanna: f = Faith::Sun; break; + case Biome::Tundra: case Biome::Taiga: case Biome::Ice: f = Faith::Ancestors; break; + case Biome::Forest: case Biome::Wetland: f = Faith::Harvest; break; + case Biome::Beach: case Biome::Lake: case Biome::Ocean:f = Faith::Sea; break; + default: f = Faith::Earth; break; + } + uint32_t h = cultHash(seed ^ (rk * 40503u) ^ 0xFA17u); + if (h % 7u == 0u) f = Faith::Moon; + else if (h % 13u == 0u) f = Faith::War; + } + cu.faith = f; + + // People (demonym) + the faith's proper name, from the region's language bank. + uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u); + uint32_t faithSeed = seed ^ cultHash(rk * 40503u + 0xFA17Fu); + cu.name = "the " + namegen::makeName(nameSeed, cu.bank); + std::string root = namegen::makeName(faithSeed, cu.bank); + switch (cultHash(faithSeed) % 3u) { + case 0: cu.faithName = "the " + root + " Faith"; break; + case 1: cu.faithName = "Cult of " + root; break; + default: cu.faithName = "the " + root + " Path"; break; + } + } + + // 4) Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and + // tag the realm with its capital's culture. + for (Nation& nat : nations) { + nat.cultureId = (nat.capital >= 0 && nat.capital < (int)sSettleCulture.size()) + ? sSettleCulture[nat.capital] : -1; + uint32_t gh = cultHash((nat.id * 2654435761u) ^ seed ^ 0x604Fu) % 3u; + switch (nat.tier) { + case NationTier::CityState: nat.gov = (gh == 0) ? GovType::CityRepublic : (gh == 1) ? GovType::Tribe : GovType::Theocracy; break; + case NationTier::Kingdom: nat.gov = (gh == 0) ? GovType::Kingdom : (gh == 1) ? GovType::Duchy : GovType::Theocracy; break; + case NationTier::Empire: nat.gov = (gh == 0) ? GovType::Empire : (gh == 1) ? GovType::Autocracy: GovType::Confederation; break; + } + const std::string& capName = settlements[nat.capital].name; + switch (nat.gov) { + case GovType::Tribe: nat.name = "Chiefdom of " + capName; break; + case GovType::CityRepublic: nat.name = "Republic of " + capName; break; + case GovType::Duchy: nat.name = "Duchy of " + capName; break; + case GovType::Kingdom: nat.name = "Kingdom of " + capName; break; + case GovType::Theocracy: nat.name = capName + " Theocracy"; break; + case GovType::Confederation: nat.name = capName + " Confederation"; break; + case GovType::Empire: nat.name = capName + " Empire"; break; + case GovType::Autocracy: nat.name = capName + " Dominion"; break; + } + } + + // 5) Per-cell culture = the culture of the realm that owns the cell (reuse territory's ownership). + for (int i = 0; i < n; ++i) { + int ni = sCellNation[i]; + if (ni >= 0 && ni < (int)nations.size()) sCellCulture[i] = nations[ni].cultureId; + } +} diff --git a/src/sim/PlanetCulture.hpp b/src/sim/PlanetCulture.hpp new file mode 100644 index 0000000..ad84fe1 --- /dev/null +++ b/src/sim/PlanetCulture.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include + +// Civilization Step 4: culture, beliefs & governments. Settlements on the same continent share a +// CULTURE -- a people/language family with a dominant ethos (drawn from their environment) and a +// religion. Each realm (Step 3 Nation) gets a GOVERNMENT type folded into its name. Like territory, +// all of this is a DETERMINISTIC function of the settlement set + geography + biomes/climate, computed +// with pure hashes (no RNG touched) -- so it is recomputed rather than saved (no save-format change), +// and the live stepper rewinds it for free. + +enum class CultureEthos : uint8_t { Agrarian, Seafaring, Nomadic, Highland, Mercantile, Warlike }; +enum class Faith : uint8_t { Sun, Moon, Sea, Sky, Earth, Ancestors, War, Harvest }; + +struct Culture { + uint32_t id = 0; + int regionId = -1; // the continent/island geography-feature index this people belongs to + int bank = 0; // NameGen "language" bank (shared by the region's places) + CultureEthos ethos = CultureEthos::Agrarian; + Faith faith = Faith::Earth; + int members = 0; // number of settlements sharing this culture + double totalPop = 0.0; // summed population of those settlements + std::string name; // the people / demonym, e.g. "the Velmar" + std::string faithName; // the religion's proper name, e.g. "the Tidewardens" +}; + +const char* cultureEthosName(CultureEthos e); // "Agrarian" / "Seafaring" / "Nomadic" / ... +const char* faithFocusName(Faith f); // "Sun" / "Sea" / "Ancestors" / ... (the object of worship) diff --git a/src/sim/PlanetNation.cpp b/src/sim/PlanetNation.cpp index a8e2e8b..cc86932 100644 --- a/src/sim/PlanetNation.cpp +++ b/src/sim/PlanetNation.cpp @@ -55,6 +55,7 @@ void Planet::computeTerritory() { for (int c : order) { if (c == s) break; // order is descending -> rest are smaller if (capitalOf[c] != c) continue; // candidate must itself be a capital + if (settlements[c].regionId != settlements[s].regionId) continue; // realms stay mono-cultural (civ Step 4) double d = ang(s, c); if (d < cfg.civVassalRange * range[c] && d < joinAng) { joinAng = d; joinCap = c; } } diff --git a/src/sim/PlanetNation.hpp b/src/sim/PlanetNation.hpp index 7f744e1..62cff12 100644 --- a/src/sim/PlanetNation.hpp +++ b/src/sim/PlanetNation.hpp @@ -12,13 +12,20 @@ enum class NationTier : uint8_t { CityState, Kingdom, Empire }; +// Government type of a realm (civ Step 4). Derived from tier + a deterministic pick and folded into the +// realm's name ("Republic of X", "Duchy of X", "Holy X", "X Confederation", ...). Set by computeCultures. +enum class GovType : uint8_t { Tribe, CityRepublic, Duchy, Kingdom, Theocracy, Confederation, Empire, Autocracy }; + struct Nation { - uint32_t id = 0; - int capital = -1; // settlement index of the realm's capital (its largest city) - int members = 0; // number of settlements in the realm - double totalPop = 0.0; // summed population of its settlements - NationTier tier = NationTier::CityState; - std::string name; // e.g. "Kingdom of X" / "X Empire" / a city-state's bare name + uint32_t id = 0; + int capital = -1; // settlement index of the realm's capital (its largest city) + int members = 0; // number of settlements in the realm + double totalPop = 0.0; // summed population of its settlements + NationTier tier = NationTier::CityState; + GovType gov = GovType::Tribe; // civ Step 4: government type (set by computeCultures) + int cultureId = -1; // civ Step 4: index into `cultures` of the realm's culture (-1 = none) + std::string name; // e.g. "Kingdom of X" / "X Empire" / a city-state's bare name }; const char* nationTierName(NationTier t); // "City-state" / "Kingdom" / "Empire" +const char* govTypeName(GovType g); // "Chiefdom" / "Republic" / "Duchy" / "Theocracy" / ... diff --git a/test_culture.cpp b/test_culture.cpp new file mode 100644 index 0000000..8b3042f --- /dev/null +++ b/test_culture.cpp @@ -0,0 +1,184 @@ +// Headless test for civilization Step 4 (cultures, beliefs & governments). No display needed. +// +// g++ -std=c++17 -O2 -Isrc/sim test_culture.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/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetIO.cpp -o /tmp/tc && /tmp/tc +// +// Verifies: one culture per inhabited continent; every living settlement has a culture; valid +// ethos/faith; governments plausible per tier + reflected in the realm name; realms are mono-cultural; +// determinism + RNG isolation; save->load->recompute parity. + +#include "Planet.hpp" +#include +#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(); +} +// Grouping key matching computeCultures: continent if geography is present, else a per-bank fallback. +static int cultKey(const Settlement& s) { return (s.regionId >= 0) ? s.regionId : (-1 - s.bank); } + +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 abP = p.cfg.civAbandonPop, yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays; + p.placeSettlements(); + double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); } + + std::printf("Culture: extraction\n"); + p.computeTerritory(); + p.computeCultures(); + check(!p.cultureList().empty(), "computeCultures produces cultures"); + check((int)p.cellCulture().size() == n && (int)p.settleCulture().size() == (int)p.settlements.size(), "index arrays sized"); + + std::printf("Culture: one culture per inhabited continent\n"); + { + // The set of distinct grouping keys among living settlements == the number of cultures, and + // every living settlement's key maps to exactly one culture (and vice versa). + std::set keys; + std::map keyToCult; // grouping key -> culture index (must be consistent) + bool consistent = true, allLiving = true; + for (size_t k = 0; k < p.settlements.size(); ++k) { + const Settlement& s = p.settlements[k]; + if (s.population < abP) continue; + int ci = p.settleCulture()[k]; + if (ci < 0 || ci >= (int)p.cultureList().size()) { allLiving = false; continue; } + int key = cultKey(s); + keys.insert(key); + auto it = keyToCult.find(key); + if (it == keyToCult.end()) keyToCult[key] = ci; + else if (it->second != ci) consistent = false; // same continent -> must be same culture + } + // No two distinct keys share a culture. + std::set usedCults; + bool distinct = true; + for (auto& kv : keyToCult) { if (usedCults.count(kv.second)) distinct = false; usedCults.insert(kv.second); } + std::printf(" %d cultures, %d inhabited continents/keys\n", (int)p.cultureList().size(), (int)keys.size()); + check(allLiving, "every living settlement has a valid culture"); + check(consistent, "settlements on the same continent share one culture"); + check(distinct, "no two continents share a culture"); + check((int)p.cultureList().size() == (int)keys.size(), "exactly one culture per inhabited continent"); + } + + std::printf("Culture: valid ethos / faith / members\n"); + { + bool ethosOk = true, faithOk = true, membersOk = true, namesOk = true; + for (const Culture& c : p.cultureList()) { + if ((int)c.ethos < 0 || (int)c.ethos > 5) ethosOk = false; + if ((int)c.faith < 0 || (int)c.faith > 7) faithOk = false; + if (c.members < 1 || c.totalPop <= 0.0) membersOk = false; + if (c.name.empty() || c.faithName.empty()) namesOk = false; + } + check(ethosOk, "every culture ethos is a valid enum"); + check(faithOk, "every culture faith is a valid enum"); + check(membersOk, "every culture has >=1 member and positive population"); + check(namesOk, "every culture has a people name + a faith name"); + } + + std::printf("Culture: governments plausible per tier + reflected in realm name\n"); + { + bool govOk = true, nameOk = true, cultOk = true; + for (const Nation& nat : p.nationList()) { + bool tierOk = + (nat.tier == NationTier::CityState && (nat.gov == GovType::CityRepublic || nat.gov == GovType::Tribe || nat.gov == GovType::Theocracy)) || + (nat.tier == NationTier::Kingdom && (nat.gov == GovType::Kingdom || nat.gov == GovType::Duchy || nat.gov == GovType::Theocracy)) || + (nat.tier == NationTier::Empire && (nat.gov == GovType::Empire || nat.gov == GovType::Autocracy || nat.gov == GovType::Confederation)); + if (!tierOk) govOk = false; + // Rebuild the expected government-aware name and compare. + const std::string& cap = p.settlements[nat.capital].name; + std::string exp; + switch (nat.gov) { + case GovType::Tribe: exp = "Chiefdom of " + cap; break; + case GovType::CityRepublic: exp = "Republic of " + cap; break; + case GovType::Duchy: exp = "Duchy of " + cap; break; + case GovType::Kingdom: exp = "Kingdom of " + cap; break; + case GovType::Theocracy: exp = cap + " Theocracy"; break; + case GovType::Confederation: exp = cap + " Confederation"; break; + case GovType::Empire: exp = cap + " Empire"; break; + case GovType::Autocracy: exp = cap + " Dominion"; break; + } + if (nat.name != exp) nameOk = false; + if (nat.cultureId < 0 || nat.cultureId >= (int)p.cultureList().size()) cultOk = false; + } + check(govOk, "each realm's government fits its tier"); + check(nameOk, "each realm name reflects its government"); + check(cultOk, "each realm is tagged with a valid culture"); + } + + std::printf("Culture: realms are mono-cultural (no realm spans two continents)\n"); + { + std::map nationCulture; // nation index -> the single culture of its members + bool mono = true; + for (size_t k = 0; k < p.settlements.size(); ++k) { + int ni = p.settleNation()[k]; if (ni < 0) continue; + int ci = p.settleCulture()[k]; + auto it = nationCulture.find(ni); + if (it == nationCulture.end()) nationCulture[ni] = ci; + else if (it->second != ci) mono = false; + } + check(mono, "every settlement in a realm shares one culture"); + } + + std::printf("Culture: determinism\n"); + Planet q; q.generate(cfg); settle(q); drift(q, 400); q.placeSettlements(); + double lt2 = 0.0; for (int yr = 0; yr < 600; ++yr) { lt2 += 2.0 * yearH; q.stepCivilization(2.0 * yearH, lt2); } + q.computeTerritory(); q.computeCultures(); + { + bool same = (q.cellCulture() == p.cellCulture()) && (q.cultureList().size() == p.cultureList().size()); + if (same) for (size_t i = 0; i < q.cultureList().size(); ++i) + if (q.cultureList()[i].name != p.cultureList()[i].name || + q.cultureList()[i].ethos != p.cultureList()[i].ethos || + q.cultureList()[i].faith != p.cultureList()[i].faith) same = false; + check(same, "computeCultures is deterministic"); + } + + std::printf("Culture: 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, yearH); y.computeTerritory(); y.computeCultures(); } + } + { + 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, "computeCultures never perturbs tectonic evolution"); + } + + std::printf("Culture: save -> load -> recompute parity\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 the v20 stream"); + r.computeTerritory(); r.computeCultures(); // cultures are derived, not saved + check(r.cellCulture() == p.cellCulture(), "culture recomputed after load matches (derived, not saved)"); + } + + std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURE CHECKS PASSED\n", failures); + return failures ? 1 : 0; +}