Civ: colonization (kingdoms found colonies + islands) + reseed overlay cleanup

Two things:

1. Fix: pressing R (reseed) after civilization existed left stale territory/
   culture/war/diplomacy/trade overlay lines drawing on the new world.
   regenWorld() now clears the civ overlay segment vectors + their toggles and
   resets a civ colour mode back to Biome.

2. Feature: the settlement set is no longer fixed after U. stepColonization()
   runs once per sim year: a kingdom+ realm (totalPop >= civColonyMinPop) may
   found a new settlement on the best unclaimed, habitable, well-spaced cell
   within reach -- overland, or across water from a coastal member (so islands /
   other continents get colonised). The colony is APPENDED and bound to the
   founder's realm by allegiance (Step 5) + a colonial supply trade link
   (computeTrade, Step 7) so it survives on marginal land while the founder
   lives; when the founder's capital falls, allegiance clears -> supply ends ->
   the colony subsists locally and may wither. Deterministic (pure hashes).

   The growing set needs no save-format change: the v20 settlement block is
   variable-length, and restoreWeather now TRUNCATES settlements to the
   snapshot's count (step-back drops colonies founded after; they re-found
   identically on replay). Founding logs a kind=3 event. civColon* config knobs.

   (Determinism fix: within one stepColonization call the per-settlement arrays
   sSettleNation/sSettleAllegiance/sCivCond are resized on each append, else the
   next realm's member loop read out of bounds.)

test_colony.cpp: colonies founded (set grows), colony in founder's realm + a
supply link, cross-water/island colonies, founder loss frees + de-supplies,
step-back truncates, determinism, save round-trip. All 16 suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-07-01 14:09:22 +02:00
parent e27b91a128
commit cdf4319b31
12 changed files with 366 additions and 10 deletions

View File

@ -370,6 +370,19 @@ motivation. Recomputed each sim year; sea cyan / land+river amber routes + a gol
tradeDiploBonus 0.1 attitude nudge/yr between trade-partner realms (reward alliance)
tradeTemptWar 0.3 war-hostility weight of a wealthy weak target (tempt war)
Colonization (PlanetConfig, derived, no save bump): once per sim year a kingdom+ realm founds a new
settlement on good unclaimed land -- overland, or across water from a coastal member (islands). Colonies
are bound to the founder by allegiance + a colonial supply trade link, surviving while the founder lives.
civColonizeRate 0.15 per-eligible-realm per-year chance to found a colony
civColonyMinPop 2e5 a realm must reach this total population to colonize (kingdoms+)
civColonyMinHab 0.30 minimum habitability of a colony site
civColonyReach 0.12 rad overland colonization reach from a realm settlement
civColonySeaReach 0.35 rad extra reach over water from a coastal member (islands / abroad)
civColonySpacing 0.05 rad a colony must be at least this far from every settlement
civMaxColonies 120 cap on colonies founded beyond civMaxSettlements
civColonySupply 2.0 prosperity from the overlord supply link (keeps colonies alive)
Culture, beliefs & governments (key X — derived from settlements + geography, not saved): settlements
on a continent share a culture (a people with an environment-driven ethos + a religion); each realm
gets a government type folded into its name (Republic of X / Duchy of X / X Theocracy / ...); realms
@ -390,7 +403,7 @@ src/sim/PlanetCulture.cpp (computeCultures).
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# Same source list for every suite: swap test_logic.cpp -> test_biota / test_live / test_ocean /
# test_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation / test_culture / test_conflict / test_diplomacy / test_trade.
# test_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation / test_culture / test_conflict / test_diplomacy / test_trade / test_colony.
# The CMake build also includes test_events for the viewer event journal.
Verifies geometry, plate assignment, gradual non-saturating relief and

View File

@ -233,6 +233,22 @@ on the Live World clock). **Steps 17 of the roadmap are done (plus a derived
sim year, no save state, no version bump**, rewinds for free. Render: a **Wealth heat map** (`wealthColor`,
gold hubs) + a **trade-route overlay** (sea cyan / river+land amber) under key **`Z`**, a gold wealth dot
per settlement in the **Civ** tab, and a cell-info prosperity/route-count line. Knobs `trade*`.
- **Colonization (kingdoms found colonies + islands)** *(done — see `PlanetCiv.cpp` `stepColonization`)*
the settlement set is no longer fixed after `U`. Once per sim year a **kingdom+** realm (`totalPop ≥
civColonyMinPop`) may **found a new settlement** on the best **unclaimed** (wilderness) high-habitability,
well-spaced cell within reach of a member — **overland** (`civColonyReach`) or **across water** from a
**coastal** member (`civColonySeaReach`, so **islands / other continents** are colonised). The colony is
**appended** to `settlements`, bound to the founder's realm by **allegiance** (Step 5, so it joins the
founder's realm even across cultures/water) and given a **colonial supply trade link** to the founder
(Step 7, `civColonySupply`) so its prosperity keeps it alive on marginal land — **as long as the founder
is alive** (when the founder's capital falls, Step-5 pruning clears the allegiance → the supply ends →
the colony subsists on local food/trade and may wither). Deterministic (pure hashes, no RNG); the set
**grows by appending** (saved as-is — the v20 settlement block is variable-length; **no save-version
bump**), and **step-back truncates** it (`restoreWeather` resizes to the snapshot's settlement count → a
colony re-founds identically on replay). Founding logs a **kind=3** event ("The Kingdom of X founds the
colony of Y"). Knobs `civColon*`. *(Also fixed: pressing `R` (reseed) after civilization existed left
stale territory/culture/war/diplomacy/trade overlay lines on the new world — `regenWorld()` now clears
the civ overlay vectors + toggles.)*
*Next steps (not yet built): cultural **evolution** (spread along borders, drift, assimilation, schism);
tribute / vassalage treaties; an accumulated treasury (funding armies / buying peace).*
@ -643,7 +659,7 @@ src/
NameGen.* deterministic procedural name generator (syllable banks; reused by civ arc)
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)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization/stepColonization (settlements + colonies; civ Step 2)
PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3)
PlanetCulture.* computeCultures (cultures/ethos/faith per continent + governments; civ Step 4)
PlanetConflict.* stepConflict (wars/conquest/revolts + diplomacy/alliances/coalitions; civ Steps 5-6, save v21/v22)
@ -716,8 +732,8 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`,
`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp` or `test_trade.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions /
Civilization / Nation / Culture / Conflict / Diplomacy / Trade suites — same source list. CMake also builds `test_events` for the
`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp` or `test_colony.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions /
Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony 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
@ -1019,6 +1035,14 @@ triangles (plates are fixed in phase 1).
`rebuildTerritory` (no RNG, not saved). Wealth-ramp + route colours are render constants
(Colors.cpp/Overlays.cpp). Raise `tradeProsperityWeight` for a more hub-dominated world, `tradeSeaRange`
for more maritime trade.
- **Colonization (`civColon*` / `civMaxColonies` in PlanetConfig / `planet.cfg`; derived, no save bump):**
`civColonizeRate` (0.15, per-eligible-realm per-year chance to found a colony), `civColonyMinPop` (2e5,
a realm must reach this to colonize — "kingdoms"), `civColonyMinHab` (0.30, min site habitability),
`civColonyReach` (0.12 rad, overland reach) / `civColonySeaReach` (0.35, extra reach over water from a
coastal member → islands), `civColonySpacing` (0.05 rad, min gap from existing settlements),
`civMaxColonies` (120, cap beyond `civMaxSettlements`), `civColonySupply` (2.0, prosperity from the
overlord supply link that keeps colonies alive). Runs in the yearly `stepColonization` tick; raise the
rate/reach for aggressive colonial empires.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -78,7 +78,7 @@ if(UNIX AND NOT APPLE)
endif()
enable_testing()
foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade)
foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony)
add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name})

View File

@ -529,6 +529,29 @@ colour mode (`wealthColor`, normalised to the current max) + `buildTradeRoutes`
amber) under key `Z`, a gold wealth dot per settlement in the Civ tab, a cell-info prosperity/route line.
Knobs `trade*`.
## Colonization (kingdoms found colonies + islands)
`Planet::stepColonization(long year)` (`PlanetCiv.cpp`) runs once per sim year (viewer year-tick, after
`stepConflict`, before `rebuildTerritory`). The settlement set is no longer fixed: a **kingdom+** realm
(`totalPop ≥ civColonyMinPop`) that passes a deterministic `civHashf(capital, year, seed) < civColonizeRate`
gate **founds** a new settlement on the best **unclaimed** (`sCellNation[c] == -1`), habitable, well-spaced
cell within `civColonyReach` overland — or `civColonySeaReach` from a **coastal** member, so islands / other
continents are reachable across water. The colony is **appended** to `settlements`, bound to the founder by
**allegiance** (Step 5 → joins the founder's realm across cultures) and by a **colonial supply link** added
in `computeTrade` (Step 7: any settlement with a living-overlord allegiance gets a distance-independent
supply `TradeLink` → prosperity → survives while the founder lives; Step-5 pruning clears the allegiance when
the capital falls). Pure hashes → deterministic (**critical**: within one call, the per-settlement arrays
`sSettleNation`/`sSettleAllegiance`/`sCivCond` are resized on each append, or the next realm's member loop
reads out of bounds).
The growing set is cheap because the save already stores a variable-length settlement vector (v20) and the
step-back snapshot stores per-settlement vectors whose **size is the count** — so **no `SAVE_VERSION` bump**,
`captureWeather` unchanged, and `restoreWeather` **truncates** `settlements` (+ `sCivCond`/`sCivDrought` +
rebuilds `sCellSettlement`) to `settlementPop.size()`, dropping colonies founded after the snapshot (they
re-found identically on replay). Founding logs a **kind=3** event. `regenWorld()` (reseed) now also clears
the viewer-side civ overlay segment lists + toggles (a bug fix — they used to linger on the new world).
Knobs `civColon*` / `civMaxColonies`.
## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run:
@ -544,7 +567,7 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet
src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp src/sim/PlanetTrade.cpp \
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography / test_ecoregions /
# test_civ / test_nation / test_culture / test_conflict / test_diplomacy / test_trade use the same source list.
# test_civ / test_nation / test_culture / test_conflict / test_diplomacy / test_trade / test_colony use the same source list.
```
(add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are
data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).

View File

@ -287,6 +287,14 @@ void Viewer::regenWorld() { // after generate(): geometry change
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false;
liveWorld = false; followId = 0; wxUndo.clear(); events.clear(); nextEventId = 1; // reseed/regen drops back to World Creation
liveInfoTab = 0; eventRowRects.clear(); eventRowIndices.clear();
// Civilization overlays are viewer-side segment lists; buildGeometry() cleared the engine data but not
// these, so drop them (and their toggles) on a reseed or they keep drawing over the new world.
nationBorders.clear(); cultureBorders.clear(); warFrontier.clear();
allyLinks.clear(); rivalLinks.clear(); tradeSea.clear(); tradeLand.clear();
showNationBorders = showCultureBorders = showTradeRoutes = false;
lastTerritoryYear = -1;
if (mode == ColorMode::Territory || mode == ColorMode::Culture || mode == ColorMode::Wealth)
mode = ColorMode::Biome; // civ colour views have no data until settlements are placed
planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear();
@ -776,6 +784,8 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
ConflictUpdate wu = planet.stepConflict(y);
for (const WarEvent& e : wu.events)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
for (const WarEvent& e : planet.stepColonization(y)) // kingdoms found new colonies (appends settlements)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
if (y < year) rebuildTerritory(); // recompute between years so the next year's war sees it
}
}

View File

@ -192,6 +192,10 @@ public:
void computeHabitability();
void placeSettlements();
CivUpdate stepCivilization(double dtHours, double liveTime);
// Colonization (once per sim year): a kingdom+ realm may found a new settlement on good unclaimed land
// (or an island, from a coastal member) bound to it by allegiance. Appends to `settlements`; returns
// the founding events. Deterministic (pure hashes) so step-back replays it.
std::vector<WarEvent> stepColonization(long year);
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)

View File

@ -245,3 +245,83 @@ CivUpdate Planet::stepCivilization(double dtHours, double liveTime) {
}
return up;
}
// Colonization: once per sim year, a kingdom+ realm may found a new settlement on good unclaimed land --
// overland, or across water from a coastal member (islands / abroad). The colony is APPENDED and bound to
// the founder's realm by allegiance (Step 5) + a colonial supply trade link (Step 7, in computeTrade), so
// it survives while the founder lives. Deterministic (pure hashes) so a step-back truncates + replays it.
std::vector<WarEvent> Planet::stepColonization(long year) {
std::vector<WarEvent> ev;
const int n = (int)cells.size();
if (settlements.empty() || nations.empty()) return ev;
if ((int)sCellNation.size() != n || sSettleNation.size() != settlements.size()) return ev;
if ((int)sHabitability.size() != n) return ev;
const int totalCap = std::max(0, cfg.civMaxSettlements) + std::max(0, cfg.civMaxColonies);
if ((int)settlements.size() >= totalCap) return ev;
const double sea = cfg.seaLevel, abP = cfg.civAbandonPop;
const uint32_t seed = cfg.seed ? cfg.seed : 1u;
auto alive = [&](int s) { return s >= 0 && s < (int)settlements.size() && settlements[s].population >= abP; };
auto angC = [&](int ca, int cb) { return std::acos(std::clamp(cells[ca].unit.dot(cells[cb].unit), -1.0, 1.0)); };
auto coastal = [&](int c) { for (int j : cells[c].neighbors) if (cells[j].elevation <= sea) return true; return false; };
const bool haveSettleCell = ((int)sCellSettlement.size() == n);
for (size_t ni = 0; ni < nations.size(); ++ni) {
if ((int)settlements.size() >= totalCap) break;
const Nation& nat = nations[ni];
if (nat.totalPop < cfg.civColonyMinPop) continue; // only sizeable realms (kingdoms+)
int cap = nat.capital; if (!alive(cap)) continue;
// Deterministic per-realm yearly gate.
if (civHashf((uint32_t)cap * 2654435761u ^ (uint32_t)year * 40503u ^ seed ^ 0xC0107Eu) >= cfg.civColonizeRate) continue;
// The realm's living members + which are coastal (only coastal members grant the extended sea reach).
std::vector<int> members; std::vector<char> mCoast;
for (size_t s = 0; s < settlements.size(); ++s)
if (sSettleNation[s] == (int)ni && alive((int)s)) { members.push_back((int)s); mCoast.push_back(coastal(settlements[s].cell) ? 1 : 0); }
if (members.empty()) continue;
// Best unclaimed, habitable, well-spaced site within reach of a member (sea reach from a coastal one).
int best = -1; double bestHab = -1.0;
for (int c = 0; c < n; ++c) {
if (cells[c].elevation <= sea || cells[c].biome == Biome::Ice) continue;
if (sCellNation[c] != -1) continue; // wilderness only
if (haveSettleCell && sCellSettlement[c] != -1) continue;
if (sHabitability[c] < cfg.civColonyMinHab || sHabitability[c] <= bestHab) continue;
double dAny = 1e9, dCoast = 1e9;
for (size_t m = 0; m < members.size(); ++m) {
double d = angC(c, settlements[members[m]].cell);
if (d < dAny) dAny = d;
if (mCoast[m] && d < dCoast) dCoast = d;
}
if (dAny > cfg.civColonyReach && dCoast > cfg.civColonyReach + cfg.civColonySeaReach) continue;
bool tooClose = false; // spacing from every settlement
for (size_t s = 0; s < settlements.size(); ++s)
if (alive((int)s) && angC(c, settlements[s].cell) < cfg.civColonySpacing) { tooClose = true; break; }
if (tooClose) continue;
bestHab = sHabitability[c]; best = c;
}
if (best < 0) continue;
int regId = ((int)sCellLand.size() == n) ? sCellLand[best] : -1;
int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId) : namegen::bankForRegion(cfg.seed, 2000 + best);
uint32_t nameSeed = seed ^ (uint32_t)(best * 2654435761u) ^ (uint32_t)year * 19349663u;
std::string nm = namegen::makeName(nameSeed, bank);
auto used = [&](const std::string& s) { for (const Settlement& t : settlements) if (t.name == s) return true; return false; };
for (int g = 0; used(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank);
int newIdx = (int)settlements.size();
Settlement st; st.cell = best; st.bank = bank; st.regionId = regId; st.population = cfg.civSeedPopulation;
st.name = nm; st.id = (uint32_t)newIdx + 1;
settlements.push_back(std::move(st));
if (haveSettleCell) sCellSettlement[best] = newIdx;
// Keep the per-settlement arrays in step with the grown set THIS call (they're reassigned next
// computeTerritory); without this the next realm's member loop reads sSettleNation out of bounds.
if (sSettleNation.size() != settlements.size()) sSettleNation.resize(settlements.size(), -1);
sSettleNation[newIdx] = (int)ni; // the colony is (already) the founder's realm
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.resize(settlements.size(), -1);
sSettleAllegiance[newIdx] = cap; // the colony belongs to the founder's realm
if (sCivCond.size() != settlements.size()) { sCivCond.resize(settlements.size(), 1.0); sCivDrought.resize(settlements.size(), 0.0); }
ev.push_back(WarEvent{ 1, best, "The " + nat.name + " founds the colony of " + settlements[newIdx].name,
"A new settlement rises on distant land.", 3 });
}
return ev;
}

View File

@ -62,12 +62,14 @@
D(diploAllyThreshold) D(diploNonAggThreshold) D(diploRivalThreshold) \
D(tradeLandRange) D(tradeSeaRange) D(tradeRiverBonus) D(tradeMinVolume) D(tradeProsperityWeight) \
D(tradeWarBlock) D(tradeAllyBonus) D(tradeDiploBonus) D(tradeTemptWar) \
D(civColonizeRate) D(civColonyMinPop) D(civColonyMinHab) D(civColonyReach) D(civColonySeaReach) \
D(civColonySpacing) D(civColonySupply) \
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(civEmpireMinMembers) I(warMaxConcurrent) \
I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) I(civMaxColonies) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed)
@ -320,6 +322,14 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.tradeAllyBonus, 0.0, 100.0, "tradeAllyBonus"));
E(rng(cfg.tradeDiploBonus, 0.0, 10.0, "tradeDiploBonus"));
E(rng(cfg.tradeTemptWar, 0.0, 100.0, "tradeTemptWar"));
E(rng(cfg.civColonizeRate, 0.0, 1.0, "civColonizeRate"));
E(rng(cfg.civColonyMinPop, 0.0, 1.0e12, "civColonyMinPop"));
E(rng(cfg.civColonyMinHab, 0.0, 1.0, "civColonyMinHab"));
E(rng(cfg.civColonyReach, 0.0, 3.14159, "civColonyReach"));
E(rng(cfg.civColonySeaReach, 0.0, 3.14159, "civColonySeaReach"));
E(rng(cfg.civColonySpacing, 0.0, 3.14159, "civColonySpacing"));
E(rng(cfg.civColonySupply, 0.0, 100.0, "civColonySupply"));
E(irng(cfg.civMaxColonies, 0, 1000000, "civMaxColonies"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));

View File

@ -60,6 +60,21 @@ void Planet::computeTrade() {
sProsperity[a] += vol; sProsperity[b] += vol;
}
}
// Colonial / imperial supply lines: a settlement bound by allegiance to a living overlord capital gets a
// guaranteed supply link (independent of distance -- across the sea), so colonies + conquered provinces
// stay viable while their overlord lives (Step 5 clears the allegiance when the capital falls -> supply
// ends and the colony subsists on local trade only).
if (sSettleAllegiance.size() == settlements.size() && cfg.civColonySupply > 0.0)
for (size_t s = 0; s < settlements.size(); ++s) {
if (!alive((int)s)) continue;
int ov = sSettleAllegiance[s];
if (ov < 0 || ov == (int)s || !alive(ov)) continue;
TradeKind kind = (coastal[s] || coastal[ov]) ? TradeKind::Sea : TradeKind::Land;
sTradeLinks.push_back(TradeLink{ (int)s, ov, (float)cfg.civColonySupply, kind });
sProsperity[s] += cfg.civColonySupply;
sProsperity[ov] += cfg.civColonySupply * 0.25; // the colony enriches its overlord a little
}
// A small base (local commerce) for any living settlement, then clamp.
for (size_t k = 0; k < settlements.size(); ++k)
sProsperity[k] = alive((int)k) ? std::min(3.0, sProsperity[k] + 0.1) : 0.0;

View File

@ -489,4 +489,15 @@ struct PlanetConfig {
double tradeAllyBonus = 1.5; // trade-volume multiplier between allied realms
double tradeDiploBonus = 0.1; // attitude nudge/year between trade-partner realms (reward alliance)
double tradeTemptWar = 0.3; // war-hostility weight of a wealthy target (rich neighbours tempt war)
// Civilization colonization (kingdoms found new settlements over time, incl. islands). New settlements
// are appended, bound to the founder's realm by allegiance + a colonial supply trade link. Derived +
// deterministic; the settlement set grows (saved as-is, step-back truncates), no save-format change.
double civColonizeRate = 0.15; // per-eligible-realm per-year chance to found a colony
double civColonyMinPop = 2.0e5; // a realm must reach this total population to colonize (kingdoms+)
double civColonyMinHab = 0.30; // minimum habitability of a colony site
double civColonyReach = 0.12; // rad: overland colonization reach from a realm settlement
double civColonySeaReach = 0.35; // rad: reach over water from a COASTAL member (islands / abroad)
double civColonySpacing = 0.05; // rad: a colony must be at least this far from every settlement
int civMaxColonies = 120; // cap on colonies founded beyond the initial civMaxSettlements
double civColonySupply = 2.0; // prosperity from the overlord supply link (keeps colonies alive)
};

View File

@ -43,9 +43,18 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain;
sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId;
volcanoes = s.volcanoes; sVolRng = s.volRng;
if (s.settlementPop.size() == settlements.size()) // restore populations (set is fixed)
for (size_t k = 0; k < settlements.size(); ++k) settlements[k].population = s.settlementPop[k];
sSettleAllegiance = s.settlementAllegiance; // civ Step 5: restore conquest state
// Colonies are appended settlements, so the snapshot count may be < the current count -- TRUNCATE back,
// dropping any colony founded after this frame (it re-founds identically on the deterministic replay).
size_t cnt = s.settlementPop.size();
if (cnt <= settlements.size()) {
settlements.resize(cnt);
for (size_t k = 0; k < cnt; ++k) settlements[k].population = s.settlementPop[k];
sCivCond.assign(cnt, 1.0); sCivDrought.assign(cnt, 0.0);
sCellSettlement.assign(cells.size(), -1);
for (size_t k = 0; k < cnt; ++k)
if (settlements[k].cell >= 0 && settlements[k].cell < (int)cells.size()) sCellSettlement[settlements[k].cell] = (int)k;
}
sSettleAllegiance = s.settlementAllegiance; // civ Step 5: restore conquest/colony allegiance
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1);
wars = s.wars; sWarRng = s.warRng ? s.warRng : sWarRng; sWarNextId = s.warNextId ? s.warNextId : sWarNextId;
diplomacy = s.diplomacy; // civ Step 6: restore realm relations

157
test_colony.cpp Normal file
View File

@ -0,0 +1,157 @@
// Headless test for civilization colonization (kingdoms found new settlements + islands). No display.
//
// g++ -std=c++17 -O2 -Isrc/sim test_colony.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
// ... (the same source list as test_civ) ... src/sim/PlanetTrade.cpp src/sim/PlanetIO.cpp \
// -o /tmp/tcol && /tmp/tcol
//
// Verifies: colonies are founded (the settlement count grows); a colony belongs to its founder's realm
// (via allegiance); a colony has a supply link to its overlord + is more prosperous for it; killing the
// founder capital frees + de-supplies the colony; step-back truncates the set; determinism; save round-trip.
#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();
}
// Crank colonization so it happens readily; keep the initial set + years so realms grow into kingdoms.
static void eager(Planet& p) {
p.cfg.civColonizeRate = 0.9; p.cfg.civColonyMinPop = 1000.0; p.cfg.civColonyMinHab = 0.15;
p.cfg.civColonyReach = 0.25; p.cfg.civColonySeaReach = 0.5; p.cfg.civColonySpacing = 0.03; p.cfg.civMaxColonies = 200;
}
static void seedCiv(Planet& p, double yearH) {
p.placeSettlements();
double lt = 0.0; for (int yr = 0; yr < 400; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); }
eager(p);
}
// Run C years of colonization + recompute; return the settlement count reached.
static int runColonize(Planet& p, long years) {
for (long yr = 0; yr < years; ++yr) {
p.computeTerritory(); p.computeCultures(); p.computeTrade();
p.stepColonization(yr);
}
p.computeTerritory(); p.computeCultures(); p.computeTrade();
return (int)p.settlements.size();
}
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 yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
seedCiv(p, yearH);
int initial = (int)p.settlements.size();
std::printf("Colony: kingdoms found colonies (the settlement set grows)\n");
int grown = runColonize(p, 200);
std::printf(" settlements: %d initial -> %d after colonization\n", initial, grown);
check(grown > initial, "new colonies are founded over time");
std::printf("Colony: a colony belongs to its founder's realm + has a supply link\n");
{
// A colony = a settlement (index >= initial) whose allegiance points to a living overlord capital.
int colony = -1, overlord = -1;
for (int s = initial; s < (int)p.settlements.size(); ++s) {
int ov = p.settleAllegiance()[s];
if (ov >= 0 && ov < (int)p.settlements.size() && p.settlements[ov].population >= p.cfg.civAbandonPop) { colony = s; overlord = ov; break; }
}
check(colony >= 0, "a colony bound to a living overlord exists");
if (colony >= 0) {
int cnat = p.cellNation()[p.settlements[colony].cell];
int onat = p.settleNation()[overlord];
check(cnat == onat && cnat >= 0, "the colony's cell belongs to the founder's realm");
bool supplied = false;
for (const TradeLink& t : p.tradeLinks())
if ((t.a == colony && t.b == overlord) || (t.a == overlord && t.b == colony)) supplied = true;
check(supplied, "the colony has a supply trade link to its overlord");
check(p.prosperity()[colony] > 0.5, "the colony is prosperous (kept alive by supply)");
}
}
std::printf("Colony: island / cross-water colonies appear\n");
{
// A colony whose nearest OWN-REALM settlement is separated by ocean (a great-circle sample hits sea).
auto crossesSea = [&](int ca, int cb) {
for (int i = 1; i < 12; ++i) {
double u = i / 12.0;
Vec3 m = (p.cells[ca].unit * (1.0 - u) + p.cells[cb].unit * u).normalized();
double best = -2; int bc = 0;
for (int c = 0; c < n; ++c) { double d = p.cells[c].unit.dot(m); if (d > best) { best = d; bc = c; } }
if (p.cells[bc].elevation <= p.cfg.seaLevel) return true;
}
return false;
};
int island = 0;
for (int s = initial; s < (int)p.settlements.size(); ++s) {
int ov = p.settleAllegiance()[s]; if (ov < 0) continue;
if (crossesSea(p.settlements[s].cell, p.settlements[ov].cell)) { ++island; }
}
std::printf(" %d colonies are across water from their overlord\n", island);
check(island > 0, "at least one colony was founded across the sea (island / abroad)");
}
std::printf("Colony: losing the founder frees + de-supplies the colony\n");
{
int colony = -1, overlord = -1;
for (int s = initial; s < (int)p.settlements.size(); ++s) {
int ov = p.settleAllegiance()[s];
if (ov >= 0 && p.settlements[ov].population >= p.cfg.civAbandonPop) { colony = s; overlord = ov; break; }
}
if (colony < 0) { check(true, "(no living-overlord colony -- skipped)"); }
else {
double prospBefore = p.prosperity()[colony];
p.settlements[overlord].population = 0.0; // the founder capital falls
p.stepConflict(9999); // Step-5 pruning clears the dead overlord's allegiance
p.computeTerritory(); p.computeCultures(); p.computeTrade();
bool freed = p.settleAllegiance()[colony] < 0;
std::printf(" colony prosperity %.2f -> %.2f (freed: %s)\n", prospBefore, p.prosperity()[colony], freed ? "yes" : "no");
check(freed, "the colony is freed when its founder collapses");
check(p.prosperity()[colony] < prospBefore, "the colony loses its supply prosperity");
}
}
std::printf("Colony: step-back truncates the settlement set\n");
{
Planet q; q.generate(cfg); settle(q); drift(q, 400); seedCiv(q, yearH);
int base = (int)q.settlements.size();
q.computeTerritory(); q.computeCultures(); q.computeTrade();
WeatherSnapshot snap = q.captureWeather(); // capture BEFORE colonizing
int after = runColonize(q, 120);
check(after > base, "colonies were founded after the snapshot");
q.restoreWeather(snap);
check((int)q.settlements.size() == base, "restoreWeather truncates back to the pre-colony count");
}
std::printf("Colony: determinism + save round-trip\n");
{
Planet a; a.generate(cfg); settle(a); drift(a, 400); seedCiv(a, yearH); int na = runColonize(a, 150);
Planet b; b.generate(cfg); settle(b); drift(b, 400); seedCiv(b, yearH); int nb = runColonize(b, 150);
bool same = (na == nb);
if (same) for (int s = 0; s < na; ++s) if (a.settlements[s].cell != b.settlements[s].cell) same = false;
check(same, "colonization is deterministic (same colonies)");
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
a.writeState(ss);
Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true, true, true);
check(ok && (int)r.settlements.size() == na, "save/load round-trips the grown settlement set");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL COLONY CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}