Civ Step 3: territory, nations & political borders

Group settlements into realms (city-states / kingdoms / empires) and give them
territory + political borders, all derived deterministically from the saved
settlement set (no save-version bump, step-back free).

- PlanetNation.{hpp,cpp}: computeTerritory() — size-scaled influence range per
  settlement, realm grouping (a town joins the nearest larger capital within its
  annexation reach, else founds its own nation), tier by member count / total pop,
  and per-cell ownership maximising range - angular-distance (wilderness frontiers
  where no settlement reaches). No RNG → tectonic stream untouched.
- Territory colour mode + nationColor, buildNationBorders (plate dual-contour
  reused), realm labels at capitals, a Realms info tab, cell-info realm line, and
  kind=4 WorldEvents (realm founded / rises to empire / collapsed). Key P toggles
  the view + borders; territory recomputed once per sim year, on placement, load
  and step-back.
- civTerritory*/civVassalRange/civEmpire* config knobs (self-describing → no save
  break); test_nation.cpp covers ownership/wilderness, empires vs city-states,
  realm grouping, tiers, determinism, RNG isolation, save/load parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-30 14:00:32 +02:00
parent 868cc90667
commit ee2ef3655b
20 changed files with 568 additions and 20 deletions

View File

@ -57,6 +57,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
E ecoregions colour view (names ecological provinces on first use; Eco tab) E ecoregions colour view (names ecological provinces on first use; Eco tab)
I habitability heat map (where civilization can thrive) I habitability heat map (where civilization can thrive)
U settlements: the dawn of civilization on first press, then toggle markers (Civ tab) 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)
Y follow-cam: cycle the 3D camera through active storms (Live World; off after last) Y follow-cam: cycle the 3D camera through active storms (Live World; off after last)
. / , step the live clock forward / back by one rate-unit (auto-pauses; back also . / , step the live clock forward / back by one rate-unit (auto-pauses; back also
rewinds weather + storms via an undo history) rewinds weather + storms via an undo history)
@ -310,6 +311,18 @@ population grows/declines on the Live World clock toward a food-driven carrying
civStormDeathRate 0.50 /yr deaths from a full-strength storm over a settlement civStormDeathRate 0.50 /yr deaths from a full-strength storm over a settlement
civHurricaneDeathMult 3.0 extra storm-death multiplier for a hurricane/typhoon civHurricaneDeathMult 3.0 extra storm-death multiplier for a hurricane/typhoon
Territory & nations (PlanetConfig, key P — derived from settlements, not saved): each settlement
projects a size-scaled influence range; nearby smaller towns become vassals of a larger capital
(a kingdom/empire), the rest are city-states; land cells inside a settlement's reach are its
territory, leaving wilderness frontiers between realms.
civTerritoryBase 0.035 rad base influence radius of any settlement
civTerritoryScale 0.05 rad extra reach per log10 of population (big cities reach far)
civTerritoryMax 0.35 rad cap on a single settlement's reach
civVassalRange 1.5 x a capital's range = how far it annexes towns into its realm
civEmpireMinMembers 5 settlements in a realm to count as an empire
civEmpirePop 5e6 total realm population to count as an empire
## Headless logic test (no display) ## Headless logic test (no display)
g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
@ -320,11 +333,11 @@ population grows/declines on the Live World clock toward a food-driven carrying
src/sim/PlanetBiota.cpp \ src/sim/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
src/sim/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp \ src/sim/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp \
src/sim/PlanetCiv.cpp \ src/sim/PlanetCiv.cpp src/sim/PlanetNation.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t 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 / # 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_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation.
# The CMake build also includes test_events for the viewer event journal. # The CMake build also includes test_events for the viewer event journal.
Verifies geometry, plate assignment, gradual non-saturating relief and Verifies geometry, plate assignment, gradual non-saturating relief and

View File

@ -108,7 +108,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
**Civilizations (in progress — the long arc after the world is finished):** the eventual goal is **Civilizations (in progress — the long arc after the world is finished):** the eventual goal is
people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural + people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural +
geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
on the Live World clock). **Steps 12 of the roadmap are done (plus a derived ecoregions atlas):** on the Live World clock). **Steps 13 of the roadmap are done (plus a derived ecoregions atlas):**
- **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the - **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the
foundation everything civic references. `Planet::generateGeography()` extracts named features from foundation everything civic references. `Planet::generateGeography()` extracts named features from
the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land), the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land),
@ -151,8 +151,23 @@ on the Live World clock). **Steps 12 of the roadmap are done (plus a derived
`WorldEvent`s ("X grew into a city", "Hurricane <name> devastates X", "Famine shrinks X to a Town", "X `WorldEvent`s ("X grew into a city", "Hurricane <name> devastates X", "Famine shrinks X to a Town", "X
was abandoned"). The set is fixed, so the step-back snapshot only restores the per-settlement was abandoned"). The set is fixed, so the step-back snapshot only restores the per-settlement
**population** vector; conditions recompute. Saved (**v20**). Knobs `civ*`. **population** vector; conditions recompute. Saved (**v20**). Knobs `civ*`.
*Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict + - **Territory, nations & political borders (Step 3)** *(done — see `PlanetNation.cpp`)* — settlements
diplomacy.* are grouped into **realms** and claim land. `computeTerritory()` (deterministic, no RNG): each living
settlement projects an **influence range** that scales with population (`civTerritory*`); **realm
grouping** processes settlements largest→smallest — a settlement joins the nearest larger **capital**
whose annexation reach (`civVassalRange`) covers it (a vassal town → kingdom) else founds its own
nation; tier = **City-state / Kingdom / Empire** by member count / total pop (`civEmpireMinMembers`/
`civEmpirePop`); each land cell goes to the settlement maximising `range distance` (else
**wilderness** 1), so its nation is that settlement's → **influence-limited territory with wilderness
frontiers**. Borders trace the per-cell `cellNation()` edges (the plate dual-contour reused as
`buildNationBorders`). Render: a **Territory** colour mode (`nationColor`) + dark border lines + realm
labels at capitals (key **`P`**), a **Realms** tab, a cell-info realm line, and kind=4 `WorldEvent`s
("The Kingdom of X is founded", "X rises to an Empire", "the X collapsed"). Territory + nations are a
**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),
conflict + diplomacy (war moving borders by force), trade.*
## Current state ## Current state
@ -562,6 +577,7 @@ src/
PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes) PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes)
PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity) PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2) PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2)
PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3)
PlanetIO.cpp config file (text) + binary save/load PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer) render/ (raylib viewer)
Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota) Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota)
@ -625,12 +641,13 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
src/sim/PlanetBiota.cpp \ src/sim/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \
src/sim/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp \ src/sim/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp \
src/sim/PlanetNation.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp` or `test_civ.cpp` `test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp` or
to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / Civilization `test_nation.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions /
suites — same source list. CMake also builds `test_events` for the Civilization / Nation suites — same source list. CMake also builds `test_events` for the
viewer event journal.) viewer event journal.)
Use this to verify tectonics after changing `Planet::step()` without launching Use this to verify tectonics after changing `Planet::step()` without launching
@ -666,6 +683,7 @@ all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured co
`V` volcano markers (Live World) · `M` place-name labels (the atlas; names the world on first use) · `V` volcano markers (Live World) · `M` place-name labels (the atlas; names the world on first use) ·
`E` ecoregions colour view (names ecology on first use) · `I` habitability heat map · `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) · `U` settlements (the dawn of civilization on first press; toggles markers after) ·
`P` territory / realms view + political borders (Realms tab lists nations) ·
`SPACE` or on-screen button pause · `SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) · `[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock `S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock
@ -879,6 +897,13 @@ triangles (plates are fixed in phase 1).
`civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). Droughts/harvests are `civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). Droughts/harvests are
deterministic per (~20° region, year, seed); an active volcano's ash within ~1.5× its blast radius also deterministic per (~20° region, year, seed); an active volcano's ash within ~1.5× its blast radius also
cuts capacity. Marker sizes/colours + hardship tint are render constants (ViewerRender.cpp). cuts capacity. Marker sizes/colours + hardship tint are render constants (ViewerRender.cpp).
- **Territory & nations (`civTerritory*`/`civVassal*`/`civEmpire*`, `planet.cfg`; key `P`):**
`civTerritoryBase` (0.035 rad, a village's reach), `civTerritoryScale` (0.05 rad per log10 of
population/seed — big cities reach far), `civTerritoryMax` (0.35 rad cap); `civVassalRange` (1.5 ×
a capital's range = its annexation reach for vassal towns → bigger = larger kingdoms); empire
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).
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -36,6 +36,7 @@ set(SIM_SOURCES
src/sim/PlanetGeography.cpp src/sim/PlanetGeography.cpp
src/sim/PlanetEcoregions.cpp src/sim/PlanetEcoregions.cpp
src/sim/PlanetCiv.cpp src/sim/PlanetCiv.cpp
src/sim/PlanetNation.cpp
src/sim/PlanetIO.cpp src/sim/PlanetIO.cpp
) )
@ -74,7 +75,7 @@ if(UNIX AND NOT APPLE)
endif() endif()
enable_testing() enable_testing()
foreach(test_name logic biota ocean live weather volcano geography ecoregions civ) foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation)
add_executable(test_${test_name} test_${test_name}.cpp) add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim) target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name}) add_test(NAME ${test_name} COMMAND test_${test_name})

View File

@ -52,6 +52,7 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
land/water context, productivity and broad biota; saved v19). land/water context, productivity and broad biota; saved v19).
- `PlanetCiv.{hpp,cpp}``computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2: - `PlanetCiv.{hpp,cpp}``computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2:
habitability + settlements that grow/decline on the live clock; saved v20). 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).
- `PlanetIO.cpp` — text config + binary save/load. - `PlanetIO.cpp` — text config + binary save/load.
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration), The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration),
@ -398,6 +399,27 @@ a cell-info line, and a `Habitability` colour mode (key `I`). `buildGeometry()`
Save **v20** stores the settlement records (population included); `sCellSettlement` is rebuilt on load. Save **v20** stores the settlement records (population included); `sCellSettlement` is rebuilt on load.
Knobs `civ*`. Knobs `civ*`.
## Civilization Step 3 — territory, nations & political borders
`PlanetNation.cpp` (engine, raylib-free, **no RNG** → tectonic stream untouched). `computeTerritory()`
turns the settlement set into **realms** + per-cell ownership, all a **pure deterministic function of
the (saved) settlements** — so it is *recomputed*, never saved (no `SAVE_VERSION` bump), and step-back
replays it as populations restore. (1) **Influence range** per living settlement scales with population
(`civTerritory*`). (2) **Realm grouping**: process settlements largest→smallest; a settlement joins the
nearest larger **capital** whose annexation reach (`civVassalRange × its range`) covers it (→ a vassal
town of that kingdom) else founds its own nation; tier City-state / Kingdom / Empire by member count /
total pop. (3) **Per-cell ownership**: each land cell goes to the settlement maximising `range
angular-distance` if > 0, else **wilderness** (1) — influence-limited, with frontiers; the cell's
nation is that settlement's. Peaceful + population-driven (no conquest yet).
Render: a `Territory` colour mode (`nationColor`, golden-ratio HSV) + dark **border lines**
(`buildNationBorders` — the plate dual-contour reused, keyed on `cellNation()`) + realm labels at
capitals (key `P`), a **Realms** tab (8th), a cell-info realm line, and **kind=4** `WorldEvent`s
(realm founded / rises to an empire / collapsed — detected in `detectNationEvents` by matching nations
across a recompute by capital id). The viewer recomputes territory + rebuilds borders **once per sim
year** (`liveAdvance` year-tick), and on placement / load / step-back; `buildGeometry()` clears
`nations`/`sCellNation`/`sSettleNation` on reseed. Knobs `civTerritory*`/`civVassal*`/`civEmpire*`.
## Headless testing ## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run: Engine is raylib-free, so logic is tested without a display. Build/run:

View File

@ -93,6 +93,7 @@ const char* colorModeName(ColorMode m) {
case ColorMode::FungaDensity: return "Funga density"; case ColorMode::FungaDensity: return "Funga density";
case ColorMode::Ecoregion: return "Ecoregions"; case ColorMode::Ecoregion: return "Ecoregions";
case ColorMode::Habitability: return "Habitability"; case ColorMode::Habitability: return "Habitability";
case ColorMode::Territory: return "Territory / realms";
case ColorMode::TempSummer: return "Temperature (summer)"; case ColorMode::TempSummer: return "Temperature (summer)";
case ColorMode::TempWinter: return "Temperature (winter)"; case ColorMode::TempWinter: return "Temperature (winter)";
case ColorMode::Seasonality: return "Seasonality (summer-winter)"; case ColorMode::Seasonality: return "Seasonality (summer-winter)";
@ -142,6 +143,12 @@ Color marineFaunaColor(double d01) { // deep blue -> cyan -> warm (rich shelve
return Color{ L(0), L(1), L(2), 255 }; return Color{ L(0), L(1), L(2), 255 };
} }
Color nationColor(int id) { // distinct per-realm tint (offset hue/sat vs plateColor)
if (id < 0) return Color{ 40, 44, 50, 255 }; // wilderness: dim grey
float h = std::fmod((id + 4) * 0.61803398875f + 0.13f, 1.0f) * 360.0f;
return ColorFromHSV(h, 0.58f, 0.92f);
}
Color habitabilityColor(double h01) { // barren grey -> green -> fertile gold Color habitabilityColor(double h01) { // barren grey -> green -> fertile gold
double t = std::clamp(h01, 0.0, 1.0); double t = std::clamp(h01, 0.0, 1.0);
static const unsigned char key[3][3] = { static const unsigned char key[3][3] = {

View File

@ -6,7 +6,7 @@
enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip, enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip,
FloraDensity, FaunaDensity, FungaDensity, FloraDensity, FaunaDensity, FungaDensity,
Ecoregion, Habitability, Ecoregion, Habitability, Territory,
TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views
Color elevationColor(double e, double seaLevel); Color elevationColor(double e, double seaLevel);
@ -42,3 +42,5 @@ Color marineFaunaColor(double d01);
Color ecoregionColor(int id, Biome b, double productivity); Color ecoregionColor(int id, Biome b, double productivity);
// Habitability heat map (0..1): barren grey -> fertile green/gold (where civilization can thrive). // Habitability heat map (0..1): barren grey -> fertile green/gold (where civilization can thrive).
Color habitabilityColor(double h01); Color habitabilityColor(double h01);
// Per-nation territory tint (golden-ratio HSV, offset from plateColor so realms read distinctly).
Color nationColor(int id);

View File

@ -41,6 +41,34 @@ void buildBorders(const Planet& p, float radius,
} }
} }
void buildNationBorders(const Planet& p, float radius, 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;
return Vector3{ (float)m.x, (float)m.y, (float)m.z };
};
auto emit = [&](const Vector3& a, const Vector3& b) { segs.push_back(a); segs.push_back(b); };
const std::vector<int>& tri = p.triIndices();
for (size_t k = 0; k + 2 < tri.size(); k += 3) {
int ia = tri[k], ib = tri[k + 1], ic = tri[k + 2];
int na = cn[ia], nb = cn[ib], nc = cn[ic];
if (na == nb && nb == nc) continue;
if (na != nb && nb != nc && na != nc) {
Vec3 c = ((p.cells[ia].unit + p.cells[ib].unit + p.cells[ic].unit) * (1.0 / 3.0)).normalized() * radius;
Vector3 C{ (float)c.x, (float)c.y, (float)c.z };
emit(C, midV(ia, ib)); emit(C, midV(ib, ic)); emit(C, midV(ic, ia));
} else {
int lone, o1, o2;
if (na == nb) { lone = ic; o1 = ia; o2 = ib; }
else if (nb == nc) { lone = ia; o1 = ib; o2 = ic; }
else { lone = ib; o1 = ia; o2 = ic; }
emit(midV(lone, o1), midV(lone, o2));
}
}
}
void buildDriftArrows(const Planet& p, float radius, void buildDriftArrows(const Planet& p, float radius,
std::vector<Vector3>& out, std::vector<PlateLabel>& labels) { std::vector<Vector3>& out, std::vector<PlateLabel>& labels) {
out.clear(); labels.clear(); out.clear(); labels.clear();

View File

@ -14,6 +14,11 @@
void buildBorders(const Planet& p, float radius, void buildBorders(const Planet& p, float radius,
std::vector<Vector3>& real, std::vector<Vector3>& ridge); std::vector<Vector3>& real, std::vector<Vector3>& ridge);
// ---- Nation borders (civ Step 3) -------------------------------------------
// 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);
// ---- Per-plate drift arrows ------------------------------------------------- // ---- Per-plate drift arrows -------------------------------------------------
struct PlateLabel { int id; Vector3 pos; }; struct PlateLabel { int id; Vector3 pos; };
void buildDriftArrows(const Planet& p, float radius, void buildDriftArrows(const Planet& p, float radius,

View File

@ -105,6 +105,17 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
} }
} }
} }
// Territory: which realm controls this cell (civ Step 3).
if (p.nationsBuilt()) {
const auto& cn = p.cellNation();
int ni = (i < (int)cn.size()) ? cn[i] : -1;
if (ni >= 0 && ni < (int)p.nationList().size()) {
const Nation& nat = p.nationList()[ni];
L.push_back(std::string("realm: ") + nat.name + " (" + nationTierName(nat.tier) + ")");
} else if (p.cells[i].elevation > p.cfg.seaLevel) {
L.push_back(std::string("realm: wilderness"));
}
}
// Climate (derived; present once computeClimate() has run). // Climate (derived; present once computeClimate() has run).
if (sized(p.temperature()) && sized(p.moisture())) if (sized(p.temperature()) && sized(p.moisture()))
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%", L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",

View File

@ -122,6 +122,7 @@ void Viewer::recolor() {
const std::vector<int>& ecoCell = planet.cellEcoregion(); const std::vector<int>& ecoCell = planet.cellEcoregion();
const auto& eco = planet.ecoregions(); const auto& eco = planet.ecoregions();
const std::vector<double>& hab = planet.habitability(); const std::vector<double>& hab = planet.habitability();
const std::vector<int>& cnat = planet.cellNation();
vcolors.resize(planet.cells.size()); vcolors.resize(planet.cells.size());
for (size_t i = 0; i < planet.cells.size(); ++i) { for (size_t i = 0; i < planet.cells.size(); ++i) {
switch (mode) { switch (mode) {
@ -160,6 +161,13 @@ void Viewer::recolor() {
&& planet.cells[i].biome != Biome::Ice) && planet.cells[i].biome != Biome::Ice)
? habitabilityColor(hab[i]) : Color{30, 42, 64, 255}; // ocean/ice: dim blue ? habitabilityColor(hab[i]) : Color{30, 42, 64, 255}; // ocean/ice: dim blue
break; break;
case ColorMode::Territory: {
int ni = (i < (int)cnat.size()) ? cnat[i] : -1;
if (ni >= 0) vcolors[i] = nationColor(ni); // owned: realm tint
else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // wilderness 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); default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel);
} }
} }
@ -389,6 +397,35 @@ void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
} }
} }
// Nation/realm events (kind=4): compare the new realms to the pre-recompute set by capital settlement id
// -> realm foundings, tier rises (to a kingdom/empire), and collapses (capital lost/absorbed).
void Viewer::detectNationEvents(const std::vector<Nation>& before) {
auto byCapital = [](const std::vector<Nation>& v, int cap) -> const Nation* {
for (const Nation& nn : v) if (nn.capital == cap) return &nn; return nullptr;
};
for (const Nation& nat : planet.nationList()) {
if (nat.capital < 0 || nat.capital >= (int)planet.settlements.size()) continue;
int cell = planet.settlements[nat.capital].cell;
const Nation* o = byCapital(before, nat.capital);
if (!o) {
if (nat.tier != NationTier::CityState) // skip lone city-state spam
appendEvent(4, 1, liveTime, cell, nat.id, std::string("The ") + nat.name + " is founded",
std::string(TextFormat("%d settlements, pop %.0fk", nat.members, nat.totalPop / 1.0e3)));
} else if ((int)nat.tier > (int)o->tier) {
appendEvent(4, 1, liveTime, cell, nat.id, nat.name + " rises to " +
(nat.tier == NationTier::Empire ? "an Empire" : "a Kingdom"),
std::string(TextFormat("%d settlements", nat.members)));
}
}
for (const Nation& o : before) {
if (o.tier == NationTier::CityState) continue;
if (!byCapital(planet.nationList(), o.capital) &&
o.capital >= 0 && o.capital < (int)planet.settlements.size())
appendEvent(4, 2, liveTime, planet.settlements[o.capital].cell, o.id,
std::string("The ") + o.name + " collapsed", "");
}
}
void Viewer::focusCell(int idx, const std::string& status) { void Viewer::focusCell(int idx, const std::string& status) {
if (idx < 0 || idx >= (int)planet.cells.size()) return; if (idx < 0 || idx >= (int)planet.cells.size()) return;
selectedCell = idx; selectedCell = idx;
@ -595,6 +632,7 @@ void Viewer::loadGame(const char* path) {
buildBorders(planet, borderR, borders, ridgeBorders); buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildDriftArrows(planet, driftR, driftArrows, plateLabels);
buildMap2D(planet, mapRect, map2D); buildMap2D(planet, mapRect, map2D);
if (planet.settlementsPlaced()) rebuildTerritory(); // territory/nations are derived -> recompute
refreshView(); refreshView();
setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)" setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)"
: std::string("Loaded ") + path); : std::string("Loaded ") + path);
@ -677,11 +715,31 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather); VolcanoUpdate vu = planet.stepVolcanoes(dtWeather);
CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements); if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements);
// Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines).
bool territoryChanged = false;
if (planet.settlementsPlaced()) {
double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
long year = (long)std::floor(liveTime / yearHours);
if (year != lastTerritoryYear) {
std::vector<Nation> beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector<Nation>{};
rebuildTerritory();
if (dtWeather > 0.0) detectNationEvents(beforeNations);
territoryChanged = true;
}
}
if (vu.breach) refreshView(); if (vu.breach) refreshView();
else if (vu.recolor || cu.recolor) recolor(); else if (vu.recolor || cu.recolor || (territoryChanged && mode == ColorMode::Territory)) recolor();
rebuildLiveOverlay(); rebuildLiveOverlay();
} }
// Recompute realms/territory from the (derived) settlement set + rebuild the nation-border segments.
void Viewer::rebuildTerritory() {
planet.computeTerritory();
buildNationBorders(planet, borderR, nationBorders);
double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
lastTerritoryYear = (long)std::floor(liveTime / yearHours);
}
// Push the current (pre-advance) weather state onto the bounded step-back ring. // Push the current (pre-advance) weather state onto the bounded step-back ring.
void Viewer::wxPushSnapshot() { void Viewer::wxPushSnapshot() {
if ((int)wxUndo.size() >= wxUndoMax) wxUndo.erase(wxUndo.begin()); if ((int)wxUndo.size() >= wxUndoMax) wxUndo.erase(wxUndo.begin());

View File

@ -101,7 +101,10 @@ struct Viewer {
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V) bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V)
bool showNames = false; // geographic place-name labels (the atlas, key M) bool showNames = false; // geographic place-name labels (the atlas, key M)
bool showSettlements = true; // civilization settlement markers (key U seeds + toggles) bool showSettlements = true; // civilization settlement markers (key U seeds + toggles)
std::vector<int> atlasRowCells; // cell to focus per visible Atlas/Eco/Civ-tab row (parallel to the list) 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)
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. // World event journal: currently Live World events, shaped to be reused by later phases.
struct WorldEvent { struct WorldEvent {
@ -153,6 +156,7 @@ struct Viewer {
void selectCell(int idx); void selectCell(int idx);
void recolor(); void recolor();
void refreshView(); void refreshView();
void rebuildTerritory(); // recompute nations/territory + nation-border segments
void rebuildLiveOverlay(); // Live World: fill illum + shadedColors from sim fields void rebuildLiveOverlay(); // Live World: fill illum + shadedColors from sim fields
// Colors the 3D globe + 2D map actually draw: the live overlay when in Live World, else the // Colors the 3D globe + 2D map actually draw: the live overlay when in Live World, else the
// plain per-cell colours. // plain per-cell colours.
@ -175,6 +179,7 @@ struct Viewer {
void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms, void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes, const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements); const std::vector<Settlement>& beforeSettlements);
void detectNationEvents(const std::vector<Nation>& beforeNations);
void focusCell(int idx, const std::string& status = ""); void focusCell(int idx, const std::string& status = "");
// ---- Input (ViewerInput.cpp) -------------------------------------------- // ---- Input (ViewerInput.cpp) --------------------------------------------

View File

@ -205,6 +205,7 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_U) && settled) { // civilization: seed on first press ("the dawn"), then toggle markers if (IsKeyPressed(KEY_U) && settled) { // civilization: seed on first press ("the dawn"), then toggle markers
if (!planet.settlementsPlaced()) { if (!planet.settlementsPlaced()) {
planet.placeSettlements(); planet.placeSettlements();
rebuildTerritory(); // initial realms + borders
showSettlements = true; showSettlements = true;
appendEvent(3, 1, liveTime, planet.settlements.empty() ? 0 : planet.settlements[0].cell, 0, appendEvent(3, 1, liveTime, planet.settlements.empty() ? 0 : planet.settlements[0].cell, 0,
"Civilization begins", "Civilization begins",
@ -215,6 +216,16 @@ void Viewer::handleInput() {
setStatus(showSettlements ? "Settlements on" : "Settlements off"); setStatus(showSettlements ? "Settlements on" : "Settlements off");
} }
} }
if (IsKeyPressed(KEY_P) && settled) { // toggle the territory / realms colour view + borders
if (!planet.settlementsPlaced()) setStatus("Press U for the dawn of civilization first");
else {
if (!planet.nationsBuilt() || (int)planet.cellNation().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Territory) ? ColorMode::Biome : ColorMode::Territory;
showNationBorders = (mode == ColorMode::Territory);
recolor();
setStatus(mode == ColorMode::Territory ? "Territory / realms on" : "Territory off");
}
}
if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock) if (IsKeyPressed(KEY_W) && settled) { // enter / leave Live World (slow real-time clock)
liveWorld = !liveWorld; liveWorld = !liveWorld;
if (liveWorld) { if (liveWorld) {

View File

@ -82,6 +82,14 @@ void Viewer::renderGlobe3D() {
} }
rlEnd(); rlSetLineWidth(1.0f); rlEnd(); rlSetLineWidth(1.0f);
} }
if (showNationBorders && !nationBorders.empty()) { // political / realm borders (dark, over the tint)
rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(18, 18, 26, 235);
for (size_t i = 0; i + 1 < nationBorders.size(); i += 2) {
rlVertex3f(nationBorders[i].x, nationBorders[i].y, nationBorders[i].z);
rlVertex3f(nationBorders[i + 1].x, nationBorders[i + 1].y, nationBorders[i + 1].z);
}
rlEnd(); rlSetLineWidth(1.0f);
}
if (showDrift && !driftArrows.empty()) { if (showDrift && !driftArrows.empty()) {
rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255);
for (size_t i = 0; i + 1 < driftArrows.size(); i += 2) { for (size_t i = 0; i + 1 < driftArrows.size(); i += 2) {
@ -335,6 +343,7 @@ void Viewer::renderMap2D() {
if (showGrat) { drawGraticule2D(graticule, vr, mapLon); drawGraticuleLabels2D(vr, mapLon); } if (showGrat) { drawGraticule2D(graticule, vr, mapLon); drawGraticuleLabels2D(vr, mapLon); }
if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f, vr, mapLon); 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 (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 (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, 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 (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, vr, mapLon);
if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, vr, mapLon); if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, vr, mapLon);
@ -434,10 +443,10 @@ void Viewer::renderLiveInfo() {
DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255}); DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255});
int x = (int)r.x + 14, y = (int)r.y + 10; int x = (int)r.x + 14, y = (int)r.y + 10;
DrawText("Live info", x, y, 20, RAYWHITE); DrawText("Live info", x, y, 20, RAYWHITE);
const char* tabs[7] = { "Sky", "Tides", "Weather", "Events", "Atlas", "Eco", "Civ" }; const char* tabs[8] = { "Sky", "Tides", "Weather", "Events", "Atlas", "Eco", "Civ", "Realms" };
float tx = r.x + 10.0f, ty = r.y + 38.0f; float tx = r.x + 10.0f, ty = r.y + 38.0f;
for (int i = 0; i < 7; ++i) { for (int i = 0; i < 8; ++i) {
float tw = (r.width - 20.0f) / 7.0f; float tw = (r.width - 20.0f) / 8.0f;
Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f }; Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f };
liveInfoTabRects.push_back(tr); liveInfoTabRects.push_back(tr);
bool on = liveInfoTab == i; bool on = liveInfoTab == i;
@ -557,7 +566,7 @@ void Viewer::renderLiveInfo() {
: Color{175, 205, 235, 255}; : Color{175, 205, 235, 255};
DrawRectangleRec(row, bg); DrawRectangleRec(row, bg);
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255});
const char* icon = e.kind == 2 ? "^" : "~"; const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : "~";
DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg); DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg);
double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours); double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours);
DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255}); DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255});
@ -633,7 +642,7 @@ void Viewer::renderLiveInfo() {
y += 35; y += 35;
} }
} }
} else { // Civ: settlements by population (largest first); click a row to fly there } else if (liveInfoTab == 6) { // Civ: settlements by population (largest first); click a row to fly there
const auto& S = planet.settlements; const auto& S = planet.settlements;
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
DrawText("Settlements", x, y, 18, Color{200, 205, 220, 255}); DrawText("Settlements", x, y, 18, Color{200, 205, 220, 255});
@ -665,6 +674,34 @@ void Viewer::renderLiveInfo() {
y += 20; y += 20;
} }
} }
} else { // 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});
y += 26;
if (N.empty()) {
DrawText(planet.settlementsPlaced() ? "press P for the territory view" : "press U then P", x, y, 13, Color{150, 155, 170, 255});
} else {
std::vector<int> idx(N.size());
for (size_t i = 0; i < N.size(); ++i) idx[i] = (int)i;
std::sort(idx.begin(), idx.end(), [&](int a, int b) { return N[a].totalPop > N[b].totalPop; });
for (int ni : idx) {
if (y > (int)(r.y + r.height) - 22) break;
const Nation& nat = N[ni];
int cap = (nat.capital >= 0 && nat.capital < (int)planet.settlements.size()) ? planet.settlements[nat.capital].cell : -1;
Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f };
eventRowRects.push_back(row); atlasRowCells.push_back(cap);
Color fg = nat.tier == NationTier::Empire ? Color{250, 215, 130, 255}
: nat.tier == NationTier::Kingdom ? Color{215, 210, 175, 255}
: Color{180, 190, 175, 255};
const char* tp = nat.totalPop >= 1.0e6 ? TextFormat("%.1fM", nat.totalPop / 1.0e6)
: nat.totalPop >= 1.0e3 ? TextFormat("%.0fk", nat.totalPop / 1.0e3)
: TextFormat("%.0f", nat.totalPop);
DrawText(nat.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, fg);
DrawText(TextFormat("%dx %s", nat.members, tp), (int)(r.x + r.width) - 92, (int)row.y + 3, 11, Color{150, 158, 178, 255});
y += 20;
}
}
} }
} }
@ -743,9 +780,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("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga E eco (*6 cycles mean/summer/winter/season)");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]", line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off")); showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off"));
line(TextFormat("K clouds [%s] | V volcanoes [%s] | M names [%s] | E eco | I habitability | U settlements [%s]", line(TextFormat("K clouds [%s] | V volcanoes [%s] | M names [%s] | E eco | I habitability | U settlements [%s] | P territory [%s]",
showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off", showNames ? "on" : "off",
!planet.settlementsPlaced() ? "seed" : showSettlements ? "on" : "off")); !planet.settlementsPlaced() ? "seed" : showSettlements ? "on" : "off",
showNationBorders ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-", line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off")); phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg"); line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");
@ -879,6 +917,31 @@ void Viewer::renderFrame() {
t == SettleTier::City ? Color{250, 230, 150, 255} : Color{225, 210, 175, 255}); t == SettleTier::City ? Color{250, 230, 150, 255} : Color{225, 210, 175, 255});
} }
} }
// 3D realm labels (with the territory view): name kingdoms/empires at their capital.
if (showNationBorders && !planet.nationList().empty()) {
Vec3 camPos{cam.position.x, cam.position.y, cam.position.z};
Vec3 forward = (Vec3{cam.target.x, cam.target.y, cam.target.z} - camPos).normalized();
Vec3 right = forward.cross(Vec3{cam.up.x, cam.up.y, cam.up.z}).normalized();
Vec3 up = right.cross(forward);
double fovRad = cam.fovy * M_PI / 180.0, aspect = (double)view3DW / view3DH;
double projH = std::tan(fovRad * 0.5), projW = projH * aspect;
for (const Nation& nat : planet.nationList()) {
if (nat.tier == NationTier::CityState) continue; // declutter: only multi-settlement realms
if (nat.capital < 0 || nat.capital >= (int)planet.settlements.size()) continue;
int cell = planet.settlements[nat.capital].cell;
if (cell < 0 || cell >= (int)planet.cells.size()) continue;
int font = nat.tier == NationTier::Empire ? 16 : 14;
Vec3 lp = rotateZ(planet.cells[cell].unit, planet.cfg.axialTilt)
* (visBase + (double)planet.cells[cell].elevation * elevExagg + 0.035);
if (lp.dot(camPos) <= 0.0) continue;
Vec3 rel = lp - camPos; double z = rel.dot(forward); if (z <= 0.0) continue;
float sx = (float)((rel.dot(right) / (projW * z) * 0.5 + 0.5) * view3DW);
float sy = (float)((0.5 - rel.dot(up) / (projH * z) * 0.5) * view3DH);
int w = MeasureText(nat.name.c_str(), font);
DrawText(nat.name.c_str(), (int)sx - w / 2 + 1, (int)sy - font - 7, font, Color{0, 0, 0, 205});
DrawText(nat.name.c_str(), (int)sx - w / 2, (int)sy - font - 8, font, Color{245, 235, 210, 255});
}
}
renderMap2D(); renderMap2D();
renderLiveInfo(); renderLiveInfo();

View File

@ -63,6 +63,7 @@ void Planet::buildGeometry() {
settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu; settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
sCellSettlement.assign(cells.size(), -1); sHabitability.clear(); sCellSettlement.assign(cells.size(), -1); sHabitability.clear();
sCivCond.clear(); sCivDrought.clear(); sCivCond.clear(); sCivDrought.clear();
nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear();
} }
void Planet::clearDerivedState() { void Planet::clearDerivedState() {

View File

@ -5,6 +5,7 @@
#include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota #include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota
#include "PlanetGeography.hpp" // FeatureKind, GeoFeature #include "PlanetGeography.hpp" // FeatureKind, GeoFeature
#include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate #include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate
#include "PlanetNation.hpp" // Nation, NationTier
#include "PlanetEcoregions.hpp" // Ecoregion #include "PlanetEcoregions.hpp" // Ecoregion
#include <vector> #include <vector>
#include <memory> #include <memory>
@ -22,6 +23,7 @@ public:
std::vector<GeoFeature> geoFeatures; // named geographic features / the atlas (saved v17+) std::vector<GeoFeature> geoFeatures; // named geographic features / the atlas (saved v17+)
std::vector<Ecoregion> ecoRegions; // named ecological provinces (saved v19+) std::vector<Ecoregion> ecoRegions; // named ecological provinces (saved v19+)
std::vector<Settlement> settlements; // civilization: settlements placed once, grow/decline (saved v20+) 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)
// Phase flag: false during Phase-1 forming (modest, original tectonics that // Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -187,6 +189,16 @@ public:
bool settlementsPlaced() const { return !settlements.empty(); } bool settlementsPlaced() const { return !settlements.empty(); }
const std::vector<int>& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1) const std::vector<int>& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1)
const std::vector<double>& habitability() const { return sHabitability; } // 0..1 per cell (derived) const std::vector<double>& habitability() const { return sHabitability; } // 0..1 per cell (derived)
// Territory & nations (PlanetNation.cpp). computeTerritory() groups settlements into realms
// (capital + vassal towns) and claims cells within each settlement's size-scaled influence range
// (wilderness frontiers between realms). Purely derived from the settlement set, so it is recomputed
// (on placement / load / each sim year), not saved -- step-back replays it as populations restore.
void computeTerritory();
bool nationsBuilt() const { return !nations.empty(); }
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)
// Per-settlement live conditions (derived each stepCivilization; not saved). condition = the combined // Per-settlement live conditions (derived each stepCivilization; not saved). condition = the combined
// environmental multiplier on carrying capacity (1 = normal, <1 = hardship, >1 = boom); drought = // 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. // current drought severity 0..1. Parallel to `settlements`. Used by the viewer for tint + events.
@ -318,6 +330,9 @@ private:
std::vector<double> sHabitability; std::vector<double> sHabitability;
std::vector<double> sCivCond, sCivDrought; // per-settlement live conditions (derived) std::vector<double> sCivCond, sCivDrought; // per-settlement live conditions (derived)
uint32_t sCivRng = 1; uint32_t sCivRng = 1;
// 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;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded. // on-demand discrete population (saved). sHasBiota latches once generated/loaded.

View File

@ -55,12 +55,13 @@
D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \ D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \
D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \
D(civStormDeathRate) D(civHurricaneDeathMult) \ D(civStormDeathRate) D(civHurricaneDeathMult) \
D(civTerritoryBase) D(civTerritoryScale) D(civTerritoryMax) D(civVassalRange) D(civEmpirePop) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \ I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \ I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) I(civMaxSettlements) \ I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed) U(seed)
@ -279,6 +280,11 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.civFamineRate, 0.0, 10.0, "civFamineRate")); E(rng(cfg.civFamineRate, 0.0, 10.0, "civFamineRate"));
E(rng(cfg.civStormDeathRate, 0.0, 10.0, "civStormDeathRate")); E(rng(cfg.civStormDeathRate, 0.0, 10.0, "civStormDeathRate"));
E(rng(cfg.civHurricaneDeathMult, 1.0, 50.0, "civHurricaneDeathMult")); E(rng(cfg.civHurricaneDeathMult, 1.0, 50.0, "civHurricaneDeathMult"));
E(rng(cfg.civTerritoryBase, 0.0, 3.14159, "civTerritoryBase"));
E(rng(cfg.civTerritoryScale, 0.0, 3.14159, "civTerritoryScale"));
E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax"));
E(rng(cfg.civVassalRange, 0.0, 20.0, "civVassalRange"));
E(rng(cfg.civEmpirePop, 1.0, 1.0e12, "civEmpirePop"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -300,6 +306,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks")); E(irng(cfg.geoMaxPeaks, 0, 100000, "geoMaxPeaks"));
E(irng(cfg.geoOceanDeep, 1, 1000, "geoOceanDeep")); E(irng(cfg.geoOceanDeep, 1, 1000, "geoOceanDeep"));
E(irng(cfg.civMaxSettlements, 0, 1000000, "civMaxSettlements")); E(irng(cfg.civMaxSettlements, 0, 1000000, "civMaxSettlements"));
E(irng(cfg.civEmpireMinMembers, 1, 1000000, "civEmpireMinMembers"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots")); E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots")); E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots")); E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));

101
src/sim/PlanetNation.cpp Normal file
View File

@ -0,0 +1,101 @@
#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cmath>
// --- Civilization Step 3: territory & nations (realms) -----------------------
// Group settlements into realms (capital + vassal towns) and claim cells within each settlement's
// size-scaled influence range, leaving wilderness frontiers. A pure deterministic function of the
// settlement set (positions + populations) -- no RNG (tectonic stream untouched), recomputed rather
// than saved, so the live stepper rewinds it for free as populations restore.
const char* nationTierName(NationTier t) {
switch (t) {
case NationTier::Empire: return "Empire";
case NationTier::Kingdom: return "Kingdom";
case NationTier::CityState: return "City-state";
}
return "City-state";
}
void Planet::computeTerritory() {
const int n = (int)cells.size();
nations.clear();
sCellNation.assign(n, -1);
sSettleNation.assign(settlements.size(), -1);
if (settlements.empty()) return;
const double sea = cfg.seaLevel, abP = cfg.civAbandonPop;
const double seedPop = std::max(1.0, cfg.civSeedPopulation);
// Influence range each living settlement projects (0 if abandoned). Big cities reach far.
std::vector<double> range(settlements.size(), 0.0);
for (size_t k = 0; k < settlements.size(); ++k) {
if (settlements[k].population < abP || settlements[k].cell < 0 || settlements[k].cell >= n) continue;
double rr = cfg.civTerritoryBase + cfg.civTerritoryScale * std::log10(1.0 + settlements[k].population / seedPop);
range[k] = std::clamp(rr, 0.0, cfg.civTerritoryMax);
}
auto ang = [&](int a, int b) {
return std::acos(std::clamp(cells[settlements[a].cell].unit.dot(cells[settlements[b].cell].unit), -1.0, 1.0));
};
// Realm grouping: process settlements largest -> smallest; a settlement joins the nearest CAPITAL
// (a larger, already-processed settlement) whose annexation reach (civVassalRange x its range)
// covers it -> a vassal town; otherwise it founds its own nation -> a capital.
std::vector<int> order(settlements.size());
for (size_t k = 0; k < order.size(); ++k) order[k] = (int)k;
std::sort(order.begin(), order.end(), [&](int a, int b) {
if (settlements[a].population != settlements[b].population) return settlements[a].population > settlements[b].population;
return a < b;
});
std::vector<int> capitalOf(settlements.size(), -1);
for (int s : order) {
if (range[s] <= 0.0) continue;
int joinCap = -1; double joinAng = 1e9;
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
double d = ang(s, c);
if (d < cfg.civVassalRange * range[c] && d < joinAng) { joinAng = d; joinCap = c; }
}
capitalOf[s] = (joinCap >= 0) ? joinCap : s;
}
// Build nation records from the distinct capitals.
std::vector<int> capToNation(settlements.size(), -1);
for (int s : order) {
if (capitalOf[s] < 0) continue;
int cap = capitalOf[s];
if (capToNation[cap] < 0) {
capToNation[cap] = (int)nations.size();
Nation nat; nat.id = (uint32_t)nations.size() + 1; nat.capital = cap;
nations.push_back(nat);
}
int ni = capToNation[cap];
sSettleNation[s] = ni;
nations[ni].members++;
nations[ni].totalPop += settlements[s].population;
}
for (Nation& nat : nations) {
nat.tier = (nat.members >= cfg.civEmpireMinMembers || nat.totalPop >= cfg.civEmpirePop) ? NationTier::Empire
: (nat.members >= 2) ? NationTier::Kingdom : NationTier::CityState;
const std::string& capName = settlements[nat.capital].name;
nat.name = (nat.tier == NationTier::Empire) ? capName + " Empire"
: (nat.tier == NationTier::Kingdom) ? "Kingdom of " + capName
: capName; // city-state: bare
}
// Per-cell ownership: each land cell goes to the settlement whose influence reaches furthest there
// (range - distance, if > 0); else wilderness. The cell's nation is that settlement's nation.
for (int i = 0; i < n; ++i) {
if (cells[i].elevation <= sea || cells[i].biome == Biome::Ice) continue;
double bestScore = 0.0; int owner = -1;
for (size_t k = 0; k < settlements.size(); ++k) {
if (range[k] <= 0.0 || sSettleNation[k] < 0) continue;
double d = std::acos(std::clamp(cells[i].unit.dot(cells[settlements[k].cell].unit), -1.0, 1.0));
double score = range[k] - d;
if (score > bestScore) { bestScore = score; owner = (int)k; }
}
if (owner >= 0) sCellNation[i] = sSettleNation[owner];
}
}

24
src/sim/PlanetNation.hpp Normal file
View File

@ -0,0 +1,24 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
// Civilization Step 3: territory & nations (realms). Settlements are grouped into nations -- a large
// city is a capital, nearby smaller settlements its vassal towns (a kingdom); isolated settlements are
// city-states; the largest realms are empires. Each settlement projects an influence range that scales
// with its population, claiming surrounding cells (with wilderness frontiers between realms). All of
// this is a DETERMINISTIC function of the settlement set (positions + populations), so it is recomputed
// rather than saved -- no save-format change, and the live stepper rewinds it for free.
enum class NationTier : uint8_t { CityState, Kingdom, Empire };
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
};
const char* nationTierName(NationTier t); // "City-state" / "Kingdom" / "Empire"

View File

@ -413,4 +413,12 @@ struct PlanetConfig {
double civFamineRate = 0.15; // /year accelerated population loss when food < population double civFamineRate = 0.15; // /year accelerated population loss when food < population
double civStormDeathRate = 0.50; // /year population loss for a full-strength storm over a settlement double civStormDeathRate = 0.50; // /year population loss for a full-strength storm over a settlement
double civHurricaneDeathMult= 3.0; // extra storm death multiplier for a hurricane/typhoon double civHurricaneDeathMult= 3.0; // extra storm death multiplier for a hurricane/typhoon
// Territory & nations (PlanetNation.cpp): influence range each settlement projects (size-scaled),
// realm grouping (vassals/kingdoms), and the empire threshold. Derived -> recomputed, not saved.
double civTerritoryBase = 0.035; // rad: base influence range of a seed-size village (~220 km)
double civTerritoryScale = 0.05; // rad added per log10 of (population / seed) -- big cities reach far
double civTerritoryMax = 0.35; // rad: cap on a single settlement's reach (~2200 km)
double civVassalRange = 1.5; // a capital annexes smaller settlements within this x its range
int civEmpireMinMembers = 5; // realm of >= this many settlements counts as an Empire
double civEmpirePop = 5.0e6; // ...or total population >= this counts as an Empire
}; };

141
test_nation.cpp Normal file
View File

@ -0,0 +1,141 @@
// Headless test for civilization Step 3 (territory & nations / realms). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_nation.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/PlanetIO.cpp -o /tmp/tn && /tmp/tn
//
// Verifies: territory ownership + wilderness; bigger cities own more; realm grouping (kingdom vs
// city-state); tiers; ocean/ice unowned; determinism + RNG isolation; save->load->recompute parity.
#include "Planet.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#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();
}
static int ownedCells(const Planet& p, int nationIdx) {
int c = 0; for (int v : p.cellNation()) if (v == nationIdx) ++c; return c;
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 4242;
Planet p; p.generate(cfg); settle(p); drift(p, 400);
const int n = (int)p.cells.size();
const double sea = p.cfg.seaLevel, yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
p.placeSettlements();
// run civilization a while so populations diverge (capitals, towns)
double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); }
std::printf("Territory: extraction\n");
p.computeTerritory();
check(!p.nationList().empty(), "computeTerritory produces nations");
check((int)p.cellNation().size() == n && (int)p.settleNation().size() == (int)p.settlements.size(), "index arrays sized");
bool seaUnowned = true, ownedIsLand = true, idxOk = true, settleMatch = true;
int ownedLand = 0;
for (int i = 0; i < n; ++i) {
int ni = p.cellNation()[i];
if (ni >= (int)p.nationList().size()) idxOk = false;
if (ni >= 0) {
++ownedLand;
if (p.cells[i].elevation <= sea || p.cells[i].biome == Biome::Ice) ownedIsLand = false;
}
if ((p.cells[i].elevation <= sea) && ni >= 0) seaUnowned = false;
}
// every living settlement's home cell belongs to its own nation
for (size_t k = 0; k < p.settlements.size(); ++k) {
if (p.settlements[k].population < p.cfg.civAbandonPop) continue;
int ni = p.settleNation()[k];
if (ni < 0 || p.cellNation()[p.settlements[k].cell] != ni) settleMatch = false;
}
std::printf(" %d nations, %d owned land cells of %d\n", (int)p.nationList().size(), ownedLand, n);
check(idxOk, "per-cell nation indices in range");
check(seaUnowned, "ocean cells are unowned (wilderness)");
check(ownedIsLand, "owned cells are land + non-ice");
check(ownedLand > 0 && ownedLand < n, "some land is owned, some is wilderness (influence-limited)");
check(settleMatch, "a living settlement's home cell belongs to its own nation");
std::printf("Territory: bigger realms control more land\n");
{
double empCells = 0, csCells = 0; int empN = 0, csN = 0;
for (size_t ni = 0; ni < p.nationList().size(); ++ni) {
int cells = ownedCells(p, (int)ni);
if (p.nationList()[ni].tier == NationTier::Empire) { empCells += cells; ++empN; }
else if (p.nationList()[ni].tier == NationTier::CityState) { csCells += cells; ++csN; }
}
double empAvg = empN ? empCells / empN : 0.0, csAvg = csN ? csCells / csN : 0.0;
std::printf(" empire avg %.1f cells (%d) ; city-state avg %.1f cells (%d)\n", empAvg, empN, csAvg, csN);
check(empN == 0 || csN == 0 || empAvg > csAvg, "empires control more territory on average than city-states");
}
std::printf("Territory: realms (kingdoms / city-states / empires)\n");
{
int kingdoms = 0, cityStates = 0, empires = 0, multiMember = 0;
for (const Nation& nat : p.nationList()) {
if (nat.tier == NationTier::Empire) ++empires;
else if (nat.tier == NationTier::Kingdom) ++kingdoms;
else ++cityStates;
if (nat.members > 1) ++multiMember;
// a kingdom/empire name references its capital; city-states are bare
bool ok = !nat.name.empty();
if (nat.tier == NationTier::Kingdom && nat.name.rfind("Kingdom of ", 0) != 0) ok = false;
check(ok || nat.tier == NationTier::CityState || nat.tier == NationTier::Empire, "nation has a sensible name");
}
std::printf(" %d empires, %d kingdoms, %d city-states (%d multi-settlement realms)\n",
empires, kingdoms, cityStates, multiMember);
check(multiMember >= 1, "at least one realm groups multiple settlements (a kingdom forms)");
}
std::printf("Territory: 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();
bool same = (q.cellNation() == p.cellNation()) && (q.nationList().size() == p.nationList().size());
check(same, "computeTerritory is deterministic");
std::printf("Territory: 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(); }
}
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, "computeTerritory never perturbs tectonic evolution");
std::printf("Territory: 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(); // territory is derived, not saved -- recompute on both sides must match
check(r.cellNation() == p.cellNation(), "territory recomputed after load matches (derived, not saved)");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL NATION CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}