diff --git a/BUILD.md b/BUILD.md index 0ff0327..478fe0b 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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) 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) 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) @@ -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 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) 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/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/PlanetCiv.cpp src/sim/PlanetNation.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_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation. # The CMake build also includes test_events for the viewer event journal. Verifies geometry, plate assignment, gradual non-saturating relief and diff --git a/CLAUDE.md b/CLAUDE.md index 176ba92..063a93c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it. **Civilizations (in progress — the long arc after the world is finished):** the eventual goal is people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural + geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all -on the Live World clock). **Steps 1–2 of the roadmap are done (plus a derived ecoregions atlas):** +on the Live World clock). **Steps 1–3 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), @@ -151,8 +151,23 @@ on the Live World clock). **Steps 1–2 of the roadmap are done (plus a derived `WorldEvent`s ("X grew into a city", "Hurricane 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 **population** vector; conditions recompute. Saved (**v20**). Knobs `civ*`. - *Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict + - diplomacy.* +- **Territory, nations & political borders (Step 3)** *(done — see `PlanetNation.cpp`)* — settlements + 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 @@ -562,6 +577,7 @@ src/ PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes) PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity) PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2) + PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3) PlanetIO.cpp config file (text) + binary save/load render/ (raylib viewer) 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/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/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` or `test_civ.cpp` -to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / Civilization -suites — same source list. CMake also builds `test_events` for the +`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 viewer event journal.) 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) · `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) · `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 @@ -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 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). +- **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 knob for how fast/high relief builds. - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks diff --git a/CMakeLists.txt b/CMakeLists.txt index cfbf3be..e6b2caa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ set(SIM_SOURCES src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp + src/sim/PlanetNation.cpp src/sim/PlanetIO.cpp ) @@ -74,7 +75,7 @@ if(UNIX AND NOT APPLE) endif() 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) target_link_libraries(test_${test_name} PRIVATE planetsim_sim) add_test(NAME ${test_name} COMMAND test_${test_name}) diff --git a/docs/design-notes.md b/docs/design-notes.md index cbaf14d..7c7418f 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -52,6 +52,7 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`). land/water context, productivity and broad biota; saved v19). - `PlanetCiv.{hpp,cpp}` — `computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2: habitability + settlements that grow/decline on the live clock; saved v20). +- `PlanetNation.{hpp,cpp}` — `computeTerritory` (civ Step 3: realms + per-cell ownership; derived, not saved). - `PlanetIO.cpp` — text config + binary save/load. 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. 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 Engine is raylib-free, so logic is tested without a display. Build/run: diff --git a/src/render/Colors.cpp b/src/render/Colors.cpp index fc71f94..b0e9451 100644 --- a/src/render/Colors.cpp +++ b/src/render/Colors.cpp @@ -93,6 +93,7 @@ const char* colorModeName(ColorMode m) { case ColorMode::FungaDensity: return "Funga density"; case ColorMode::Ecoregion: return "Ecoregions"; case ColorMode::Habitability: return "Habitability"; + case ColorMode::Territory: return "Territory / realms"; case ColorMode::TempSummer: return "Temperature (summer)"; case ColorMode::TempWinter: return "Temperature (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 }; } +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 double t = std::clamp(h01, 0.0, 1.0); static const unsigned char key[3][3] = { diff --git a/src/render/Colors.hpp b/src/render/Colors.hpp index bc6f446..085b421 100644 --- a/src/render/Colors.hpp +++ b/src/render/Colors.hpp @@ -6,7 +6,7 @@ enum class ColorMode { Elevation, Plate, Age, Crust, Biome, Temperature, Precip, FloraDensity, FaunaDensity, FungaDensity, - Ecoregion, Habitability, + Ecoregion, Habitability, Territory, TempSummer, TempWinter, Seasonality }; // 6 cycles these temp sub-views Color elevationColor(double e, double seaLevel); @@ -42,3 +42,5 @@ Color marineFaunaColor(double d01); Color ecoregionColor(int id, Biome b, double productivity); // Habitability heat map (0..1): barren grey -> fertile green/gold (where civilization can thrive). Color habitabilityColor(double h01); +// Per-nation territory tint (golden-ratio HSV, offset from plateColor so realms read distinctly). +Color nationColor(int id); diff --git a/src/render/Overlays.cpp b/src/render/Overlays.cpp index 4f832ca..cc37857 100644 --- a/src/render/Overlays.cpp +++ b/src/render/Overlays.cpp @@ -41,6 +41,34 @@ void buildBorders(const Planet& p, float radius, } } +void buildNationBorders(const Planet& p, float radius, std::vector& segs) { + segs.clear(); + const std::vector& cn = p.cellNation(); + if ((int)cn.size() != (int)p.cells.size()) return; + auto midV = [&](int i, int j) -> Vector3 { + Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius; + 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& 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, std::vector& out, std::vector& labels) { out.clear(); labels.clear(); diff --git a/src/render/Overlays.hpp b/src/render/Overlays.hpp index 887e7ab..f002504 100644 --- a/src/render/Overlays.hpp +++ b/src/render/Overlays.hpp @@ -14,6 +14,11 @@ void buildBorders(const Planet& p, float radius, std::vector& real, std::vector& 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& segs); + // ---- Per-plate drift arrows ------------------------------------------------- struct PlateLabel { int id; Vector3 pos; }; void buildDriftArrows(const Planet& p, float radius, diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index 3af78a5..d1141e8 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -105,6 +105,17 @@ static std::vector 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). if (sized(p.temperature()) && sized(p.moisture())) L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%", diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 1df1753..ecbf13f 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -122,6 +122,7 @@ void Viewer::recolor() { const std::vector& ecoCell = planet.cellEcoregion(); const auto& eco = planet.ecoregions(); const std::vector& hab = planet.habitability(); + const std::vector& cnat = planet.cellNation(); vcolors.resize(planet.cells.size()); for (size_t i = 0; i < planet.cells.size(); ++i) { switch (mode) { @@ -160,6 +161,13 @@ void Viewer::recolor() { && planet.cells[i].biome != Biome::Ice) ? habitabilityColor(hab[i]) : Color{30, 42, 64, 255}; // ocean/ice: dim blue 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); } } @@ -389,6 +397,35 @@ void Viewer::detectLiveEvents(const std::vector& 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& before) { + auto byCapital = [](const std::vector& 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) { if (idx < 0 || idx >= (int)planet.cells.size()) return; selectedCell = idx; @@ -595,6 +632,7 @@ void Viewer::loadGame(const char* path) { buildBorders(planet, borderR, borders, ridgeBorders); buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildMap2D(planet, mapRect, map2D); + if (planet.settlementsPlaced()) rebuildTerritory(); // territory/nations are derived -> recompute refreshView(); setStatus(skippedHistory ? std::string("Loaded ") + path + " (history skipped)" : std::string("Loaded ") + path); @@ -677,11 +715,31 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { VolcanoUpdate vu = planet.stepVolcanoes(dtWeather); CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock 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 beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector{}; + rebuildTerritory(); + if (dtWeather > 0.0) detectNationEvents(beforeNations); + territoryChanged = true; + } + } if (vu.breach) refreshView(); - else if (vu.recolor || cu.recolor) recolor(); + else if (vu.recolor || cu.recolor || (territoryChanged && mode == ColorMode::Territory)) recolor(); 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. void Viewer::wxPushSnapshot() { if ((int)wxUndo.size() >= wxUndoMax) wxUndo.erase(wxUndo.begin()); diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 193d684..7997fb9 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -101,7 +101,10 @@ struct Viewer { bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V) bool showNames = false; // geographic place-name labels (the atlas, key M) bool showSettlements = true; // civilization settlement markers (key U seeds + toggles) - std::vector atlasRowCells; // cell to focus per visible Atlas/Eco/Civ-tab row (parallel to the list) + std::vector atlasRowCells; // cell to focus per visible Atlas/Eco/Civ/Realms-tab row (parallel to the list) + std::vector nationBorders; // political border segments (rebuilt on year tick / placement / load) + bool showNationBorders = false; // draw nation/realm borders (on with the Territory view) + 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. struct WorldEvent { @@ -153,6 +156,7 @@ struct Viewer { void selectCell(int idx); void recolor(); void refreshView(); + void rebuildTerritory(); // recompute nations/territory + nation-border segments 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 // plain per-cell colours. @@ -175,6 +179,7 @@ struct Viewer { void detectLiveEvents(const std::vector& beforeStorms, const std::vector& beforeVolcanoes, const std::vector& beforeSettlements); + void detectNationEvents(const std::vector& beforeNations); void focusCell(int idx, const std::string& status = ""); // ---- Input (ViewerInput.cpp) -------------------------------------------- diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index 8ca63c2..cb365f7 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -205,6 +205,7 @@ void Viewer::handleInput() { if (IsKeyPressed(KEY_U) && settled) { // civilization: seed on first press ("the dawn"), then toggle markers if (!planet.settlementsPlaced()) { planet.placeSettlements(); + rebuildTerritory(); // initial realms + borders showSettlements = true; appendEvent(3, 1, liveTime, planet.settlements.empty() ? 0 : planet.settlements[0].cell, 0, "Civilization begins", @@ -215,6 +216,16 @@ void Viewer::handleInput() { 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) liveWorld = !liveWorld; if (liveWorld) { diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index b40ecd5..784645c 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -82,6 +82,14 @@ void Viewer::renderGlobe3D() { } 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()) { rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); 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 (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 (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); @@ -434,10 +443,10 @@ 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[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; - for (int i = 0; i < 7; ++i) { - float tw = (r.width - 20.0f) / 7.0f; + for (int i = 0; i < 8; ++i) { + float tw = (r.width - 20.0f) / 8.0f; Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f }; liveInfoTabRects.push_back(tr); bool on = liveInfoTab == i; @@ -557,7 +566,7 @@ void Viewer::renderLiveInfo() { : Color{175, 205, 235, 255}; DrawRectangleRec(row, bg); 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); 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}); @@ -633,7 +642,7 @@ void Viewer::renderLiveInfo() { 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 double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; DrawText("Settlements", x, y, 18, Color{200, 205, 220, 255}); @@ -665,6 +674,34 @@ void Viewer::renderLiveInfo() { 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 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(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]", + 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", - !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 | +/-", phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off")); 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}); } } + // 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(); renderLiveInfo(); diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index 8245ea6..745ab1a 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -63,6 +63,7 @@ void Planet::buildGeometry() { settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu; sCellSettlement.assign(cells.size(), -1); sHabitability.clear(); sCivCond.clear(); sCivDrought.clear(); + nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear(); } void Planet::clearDerivedState() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 5c47368..20a6d51 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -5,6 +5,7 @@ #include "PlanetBiota.hpp" // BiotaKind, Organism, CellBiota #include "PlanetGeography.hpp" // FeatureKind, GeoFeature #include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate +#include "PlanetNation.hpp" // Nation, NationTier #include "PlanetEcoregions.hpp" // Ecoregion #include #include @@ -22,6 +23,7 @@ public: std::vector geoFeatures; // named geographic features / the atlas (saved v17+) std::vector ecoRegions; // named ecological provinces (saved v19+) std::vector settlements; // civilization: settlements placed once, grow/decline (saved v20+) + std::vector nations; // realms grouped from settlements (derived each computeTerritory, 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 @@ -187,6 +189,16 @@ public: bool settlementsPlaced() const { return !settlements.empty(); } const std::vector& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1) const std::vector& habitability() const { return sHabitability; } // 0..1 per cell (derived) + + // 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& nationList() const { return nations; } + const std::vector& cellNation() const { return sCellNation; } // nation index per cell (-1 = wilderness/sea) + const std::vector& settleNation() const { return sSettleNation; } // nation index per settlement (-1 = dead) // 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. @@ -318,6 +330,9 @@ private: std::vector sHabitability; std::vector sCivCond, sCivDrought; // per-settlement live conditions (derived) 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 sCellNation, sSettleNation; // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // on-demand discrete population (saved). sHasBiota latches once generated/loaded. diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 95124ed..b6a31df 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -55,12 +55,13 @@ D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \ D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ 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(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(volcanoMaxCount) \ I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \ - I(geoOceanDeep) I(civMaxSettlements) \ + I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ U(seed) @@ -279,6 +280,11 @@ std::string validateConfig(const PlanetConfig& cfg) { E(rng(cfg.civFamineRate, 0.0, 10.0, "civFamineRate")); E(rng(cfg.civStormDeathRate, 0.0, 10.0, "civStormDeathRate")); 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.plateCount, 1, 100, "plateCount")); 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.geoOceanDeep, 1, 1000, "geoOceanDeep")); E(irng(cfg.civMaxSettlements, 0, 1000000, "civMaxSettlements")); + E(irng(cfg.civEmpireMinMembers, 1, 1000000, "civEmpireMinMembers")); E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots")); E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots")); E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots")); diff --git a/src/sim/PlanetNation.cpp b/src/sim/PlanetNation.cpp new file mode 100644 index 0000000..a8e2e8b --- /dev/null +++ b/src/sim/PlanetNation.cpp @@ -0,0 +1,101 @@ +#include "Planet.hpp" +#include "NameGen.hpp" +#include +#include + +// --- 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 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 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 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 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]; + } +} diff --git a/src/sim/PlanetNation.hpp b/src/sim/PlanetNation.hpp new file mode 100644 index 0000000..7f744e1 --- /dev/null +++ b/src/sim/PlanetNation.hpp @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +// 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" diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index ecdc19d..4a3119d 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -413,4 +413,12 @@ struct PlanetConfig { 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 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 }; diff --git a/test_nation.cpp b/test_nation.cpp new file mode 100644 index 0000000..1f9257e --- /dev/null +++ b/test_nation.cpp @@ -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 +#include +#include +#include + +static int failures = 0; +static void check(bool cond, const char* what) { + std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); + if (!cond) ++failures; +} +static void settle(Planet& p, int maxSteps = 800) { + int run = 0; + for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; } + p.computeClimate(); p.classifyBiomes(); +} +static void drift(Planet& p, int iters) { + p.drifting = true; + for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); } + p.computeClimate(); p.classifyBiomes(); +} +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; +}