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:
parent
868cc90667
commit
ee2ef3655b
17
BUILD.md
17
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
|
||||
|
||||
37
CLAUDE.md
37
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 <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
|
||||
**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
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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] = {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
std::vector<Vector3>& out, std::vector<PlateLabel>& labels) {
|
||||
out.clear(); labels.clear();
|
||||
|
||||
@ -14,6 +14,11 @@
|
||||
void buildBorders(const Planet& p, float radius,
|
||||
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 -------------------------------------------------
|
||||
struct PlateLabel { int id; Vector3 pos; };
|
||||
void buildDriftArrows(const Planet& p, float radius,
|
||||
|
||||
@ -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).
|
||||
if (sized(p.temperature()) && sized(p.moisture()))
|
||||
L.push_back(std::string(TextFormat("temp %.1f C precip %.0f%%",
|
||||
|
||||
@ -122,6 +122,7 @@ void Viewer::recolor() {
|
||||
const std::vector<int>& ecoCell = planet.cellEcoregion();
|
||||
const auto& eco = planet.ecoregions();
|
||||
const std::vector<double>& hab = planet.habitability();
|
||||
const std::vector<int>& 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<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) {
|
||||
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<Nation> beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector<Nation>{};
|
||||
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());
|
||||
|
||||
@ -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<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.
|
||||
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<WeatherSystem>& beforeStorms,
|
||||
const std::vector<Volcano>& beforeVolcanoes,
|
||||
const std::vector<Settlement>& beforeSettlements);
|
||||
void detectNationEvents(const std::vector<Nation>& beforeNations);
|
||||
void focusCell(int idx, const std::string& status = "");
|
||||
|
||||
// ---- Input (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) {
|
||||
|
||||
@ -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<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(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();
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 <vector>
|
||||
#include <memory>
|
||||
@ -22,6 +23,7 @@ public:
|
||||
std::vector<GeoFeature> geoFeatures; // named geographic features / the atlas (saved v17+)
|
||||
std::vector<Ecoregion> ecoRegions; // named ecological provinces (saved v19+)
|
||||
std::vector<Settlement> settlements; // civilization: settlements placed once, grow/decline (saved v20+)
|
||||
std::vector<Nation> nations; // realms grouped from settlements (derived each computeTerritory, not saved)
|
||||
|
||||
// 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<int>& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1)
|
||||
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
|
||||
// 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<double> sHabitability;
|
||||
std::vector<double> 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<int> 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.
|
||||
|
||||
@ -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"));
|
||||
|
||||
101
src/sim/PlanetNation.cpp
Normal file
101
src/sim/PlanetNation.cpp
Normal 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
24
src/sim/PlanetNation.hpp
Normal 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"
|
||||
@ -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
|
||||
};
|
||||
|
||||
141
test_nation.cpp
Normal file
141
test_nation.cpp
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user