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 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-07-01 07:58:58 +02:00
parent 0638398d44
commit 91f1b90a93
20 changed files with 611 additions and 29 deletions

View File

@ -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

View File

@ -108,7 +108,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
**Civilizations (in progress — the long arc after the world is finished):** the eventual goal is
people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural +
geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
on the Live World clock). **Steps 13 of the roadmap are done (plus a derived ecoregions atlas):**
on the Live World clock). **Steps 14 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 13 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

View File

@ -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})

View File

@ -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).

View File

@ -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] = {

View File

@ -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);

View File

@ -41,9 +41,11 @@ void buildBorders(const Planet& p, float radius,
}
}
void buildNationBorders(const Planet& p, float radius, std::vector<Vector3>& 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<int>& cn,
std::vector<Vector3>& segs) {
segs.clear();
const std::vector<int>& 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<Vector3>& seg
}
}
void buildNationBorders(const Planet& p, float radius, std::vector<Vector3>& segs) {
buildLabelBorders(p, radius, p.cellNation(), segs);
}
void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& segs) {
buildLabelBorders(p, radius, p.cellCulture(), segs);
}
void buildDriftArrows(const Planet& p, float radius,
std::vector<Vector3>& out, std::vector<PlateLabel>& labels) {
out.clear(); labels.clear();

View File

@ -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<Vector3>& 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<Vector3>& segs);
// ---- Per-plate drift arrows -------------------------------------------------
struct PlateLabel { int id; Vector3 pos; };

View File

@ -116,6 +116,16 @@ static std::vector<std::string> 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%%",

View File

@ -123,6 +123,7 @@ void Viewer::recolor() {
const auto& eco = planet.ecoregions();
const std::vector<double>& hab = planet.habitability();
const std::vector<int>& cnat = planet.cellNation();
const std::vector<int>& 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);
}

View File

@ -104,6 +104,8 @@ struct Viewer {
std::vector<int> atlasRowCells; // cell to focus per visible Atlas/Eco/Civ/Realms-tab row (parallel to the list)
std::vector<Vector3> nationBorders; // political border segments (rebuilt on year tick / placement / load)
bool showNationBorders = false; // draw nation/realm borders (on with the Territory view)
std::vector<Vector3> 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.

View File

@ -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) {

View File

@ -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<int> 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");

View File

@ -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() {

View File

@ -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 <vector>
#include <memory>
@ -24,6 +25,7 @@ public:
std::vector<Ecoregion> ecoRegions; // named ecological provinces (saved v19+)
std::vector<Settlement> settlements; // civilization: settlements placed once, grow/decline (saved v20+)
std::vector<Nation> nations; // realms grouped from settlements (derived each computeTerritory, not saved)
std::vector<Culture> 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<Nation>& nationList() const { return nations; }
const std::vector<int>& cellNation() const { return sCellNation; } // nation index per cell (-1 = wilderness/sea)
const std::vector<int>& 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<Culture>& cultureList() const { return cultures; }
const std::vector<int>& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none)
const std::vector<int>& 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<int> 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<int> 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.

191
src/sim/PlanetCulture.cpp Normal file
View File

@ -0,0 +1,191 @@
#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cmath>
#include <unordered_map>
// --- 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<int, int> 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> 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;
}
}

29
src/sim/PlanetCulture.hpp Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
// 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)

View File

@ -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; }
}

View File

@ -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;
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" / ...

184
test_culture.cpp Normal file
View File

@ -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 <cstdio>
#include <cmath>
#include <algorithm>
#include <set>
#include <map>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
static void settle(Planet& p, int maxSteps = 800) {
int run = 0;
for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; }
p.computeClimate(); p.classifyBiomes();
}
static void drift(Planet& p, int iters) {
p.drifting = true;
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); }
p.computeClimate(); p.classifyBiomes();
}
// 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<int> keys;
std::map<int,int> 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<int> 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<int,int> 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;
}