Realms stop coexisting peacefully and go to war. Unlike the derived Steps 3-4,
war is stateful & path-dependent, so it adds saved state (allegiance + wars +
a war RNG), extends the step-back snapshot, and bumps the save to v21.
- PlanetConflict.{hpp,cpp}: stepConflict(year) runs once per sim year. Neighbouring
realms (adjacency by settlement proximity) grow hostile from ambition (size gap)
+ ideology (culture/faith difference) + contested frontier + a yearly streak, and
declare wars (cap warMaxConcurrent). Each war-year runs a battle (strength =
totalPop x Warlike bonus x defender home advantage), inflicts casualties on
frontier cities, and the winner conquers a loser frontier city (allegiance flips
to the victor) or sacks it (ruins). Conquered foreign/distant cities revolt over
time; a realm that loses its capital collapses -> empires rise and fall. Separate
sWarRng keeps tectonics deterministic.
- computeTerritory() honours sSettleAllegiance (overriding the mono-cultural rule)
with a chain-resolving capital lookup, so borders move as cities change hands.
- Save v21: allegiance + wars + war RNG (new hasConflict readState param + a
per-frame block in the step-back history); WeatherSnapshot + capture/restoreWeather
extended, so ,/. rewind conquests + revolts.
- Render: red war-front lines + a red at-war marker & active-wars list in the Realms
tab over the Territory view (P), a cell-info AT WAR flag, a HUD war count, and
kind=5 events (declare / capture / sack / revolt / peace).
- war* config knobs; test_conflict.cpp covers wars erupting, conquest moving the
border, revolts, determinism, RNG isolation, save-v21 + snapshot round-trip.
All 13 suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
9.0 KiB
C++
168 lines
9.0 KiB
C++
// Headless test for civilization Step 5 (conflict, war & shifting borders). No display needed.
|
|
//
|
|
// g++ -std=c++17 -O2 -Isrc/sim test_conflict.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \
|
|
// src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \
|
|
// src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \
|
|
// src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
|
|
// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \
|
|
// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp \
|
|
// src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp \
|
|
// src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp \
|
|
// src/sim/PlanetIO.cpp -o /tmp/twar && /tmp/twar
|
|
//
|
|
// Verifies: wars start between neighbouring realms; conquest sets allegiance so computeTerritory moves
|
|
// the border (the city joins the overlord's realm); revolts clear allegiance; determinism + RNG
|
|
// isolation; save-v21 + step-back snapshot 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 the war knobs so conquest is frequent + persistent (isolates the mechanics from randomness).
|
|
static void aggressiveWars(Planet& p) {
|
|
p.cfg.warDeclareRate = 8.0; p.cfg.warAmbition = 2.0; p.cfg.warIdeology = 2.0; p.cfg.warBorder = 2.0;
|
|
p.cfg.warConquerScore = 0.05; p.cfg.warSackChance = 0.0; p.cfg.warExhaustion = 900.0;
|
|
p.cfg.warRevoltRate = 0.0; p.cfg.warMinRealmPop = 1.0; p.cfg.warMaxConcurrent = 50; p.cfg.warCasualtyRate = 0.0;
|
|
}
|
|
static bool warsEqual(const std::vector<War>& a, const std::vector<War>& b) {
|
|
if (a.size() != b.size()) return false;
|
|
for (size_t i = 0; i < a.size(); ++i)
|
|
if (a[i].attacker != b[i].attacker || a[i].defender != b[i].defender || a[i].battles != b[i].battles
|
|
|| std::fabs(a[i].warscore - b[i].warscore) > 1e-12) return false;
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
p.placeSettlements();
|
|
double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); }
|
|
aggressiveWars(p);
|
|
|
|
std::printf("Conflict: wars erupt + conquest sets allegiance\n");
|
|
bool sawWar = false; int totalDeclared = 0;
|
|
for (long yr = 0; yr < 300; ++yr) {
|
|
p.computeTerritory(); p.computeCultures();
|
|
ConflictUpdate u = p.stepConflict(yr);
|
|
if (!p.warList().empty()) sawWar = true;
|
|
for (const WarEvent& e : u.events) if (e.title.find("declares war") != std::string::npos) ++totalDeclared;
|
|
}
|
|
p.computeTerritory(); // reflect the final allegiances
|
|
int allegianceSet = 0;
|
|
for (int a : p.settleAllegiance()) if (a >= 0) ++allegianceSet;
|
|
std::printf(" saw wars: %s ; declared over run: %d ; cities under foreign rule: %d\n",
|
|
sawWar ? "yes" : "no", totalDeclared, allegianceSet);
|
|
check(sawWar, "wars break out between neighbouring realms");
|
|
check(allegianceSet > 0, "at least one city is conquered (allegiance set)");
|
|
|
|
std::printf("Conflict: conquest moves the border (city joins its overlord's realm)\n");
|
|
{
|
|
int joined = 0, crossedCulture = 0, checkedValidOverlord = 0;
|
|
for (size_t s = 0; s < p.settlements.size(); ++s) {
|
|
int ov = p.settleAllegiance()[s];
|
|
if (ov < 0) continue;
|
|
// Is the overlord still a living capital of some nation?
|
|
int ovNation = -1;
|
|
for (size_t ni = 0; ni < p.nationList().size(); ++ni) if (p.nationList()[ni].capital == ov) ovNation = (int)ni;
|
|
if (ovNation < 0) continue;
|
|
++checkedValidOverlord;
|
|
int myNation = p.settleNation()[s];
|
|
if (myNation == ovNation && p.cellNation()[p.settlements[s].cell] == ovNation) ++joined;
|
|
if (myNation == ovNation && s < p.settleCulture().size() &&
|
|
p.settleCulture()[s] != p.settleCulture()[ov]) ++crossedCulture;
|
|
}
|
|
std::printf(" %d conquered cities have a living overlord ; %d joined it ; %d crossed a culture line\n",
|
|
checkedValidOverlord, joined, crossedCulture);
|
|
check(checkedValidOverlord == 0 || joined > 0, "a conquered city sits in its overlord's realm (border moved)");
|
|
}
|
|
|
|
std::printf("Conflict: revolts free conquered cities\n");
|
|
{
|
|
int before = 0; for (int a : p.settleAllegiance()) if (a >= 0) ++before;
|
|
p.cfg.warRevoltRate = 5.0; p.cfg.warDeclareRate = 0.0; // no new wars, heavy revolts
|
|
for (long yr = 300; yr < 320; ++yr) { p.computeTerritory(); p.computeCultures(); p.stepConflict(yr); }
|
|
int after = 0; for (int a : p.settleAllegiance()) if (a >= 0) ++after;
|
|
std::printf(" conquered cities before revolts: %d ; after: %d\n", before, after);
|
|
check(before == 0 || after < before, "revolts reduce the number of conquered cities");
|
|
}
|
|
|
|
std::printf("Conflict: 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); }
|
|
aggressiveWars(q);
|
|
for (long yr = 0; yr < 300; ++yr) { q.computeTerritory(); q.computeCultures(); q.stepConflict(yr); }
|
|
q.computeTerritory();
|
|
// Re-run p's identical sequence into a third world to compare against (p has since had revolts applied).
|
|
Planet p2; p2.generate(cfg); settle(p2); drift(p2, 400); p2.placeSettlements();
|
|
double lt3 = 0.0; for (int yr = 0; yr < 600; ++yr) { lt3 += 2.0 * yearH; p2.stepCivilization(2.0 * yearH, lt3); }
|
|
aggressiveWars(p2);
|
|
for (long yr = 0; yr < 300; ++yr) { p2.computeTerritory(); p2.computeCultures(); p2.stepConflict(yr); }
|
|
p2.computeTerritory();
|
|
check(q.settleAllegiance() == p2.settleAllegiance() && warsEqual(q.warList(), p2.warList()),
|
|
"stepConflict is deterministic (same allegiance + wars)");
|
|
|
|
std::printf("Conflict: RNG isolation from tectonics\n");
|
|
Planet x; x.generate(cfg); settle(x);
|
|
Planet y; y.generate(cfg); settle(y);
|
|
for (int k = 0; k < 40; ++k) {
|
|
double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx);
|
|
double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy);
|
|
if (k == 20) { y.placeSettlements(); y.stepCivilization(yearH, yearH); y.computeTerritory(); y.computeCultures(); aggressiveWars(y); y.stepConflict(0); }
|
|
}
|
|
{
|
|
bool same = true;
|
|
for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) same = false;
|
|
check(same, "stepConflict never perturbs tectonic evolution");
|
|
}
|
|
|
|
std::printf("Conflict: save v21 round-trip\n");
|
|
{
|
|
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
|
|
p2.writeState(ss);
|
|
Planet r;
|
|
bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true, true);
|
|
check(ok, "readState accepts the v21 stream");
|
|
check(r.settleAllegiance() == p2.settleAllegiance(), "allegiance survives save/load");
|
|
check(warsEqual(r.warList(), p2.warList()), "active wars survive save/load");
|
|
r.computeTerritory();
|
|
check(r.cellNation() == p2.cellNation(), "territory recomputed after load matches (allegiance honoured)");
|
|
}
|
|
|
|
std::printf("Conflict: step-back snapshot round-trip\n");
|
|
{
|
|
WeatherSnapshot snap = p2.captureWeather();
|
|
std::vector<int> before = p2.settleAllegiance();
|
|
std::vector<War> wbefore = p2.warList();
|
|
// Mutate: heavy revolts + more wars.
|
|
p2.cfg.warRevoltRate = 5.0; p2.cfg.warDeclareRate = 5.0;
|
|
for (long yr = 400; yr < 410; ++yr) { p2.computeTerritory(); p2.computeCultures(); p2.stepConflict(yr); }
|
|
bool changed = (p2.settleAllegiance() != before) || !warsEqual(p2.warList(), wbefore);
|
|
p2.restoreWeather(snap);
|
|
check(changed, "the mutation actually changed conflict state");
|
|
check(p2.settleAllegiance() == before && warsEqual(p2.warList(), wbefore),
|
|
"restoreWeather rewinds allegiance + wars (step-back)");
|
|
}
|
|
|
|
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CONFLICT CHECKS PASSED\n", failures);
|
|
return failures ? 1 : 0;
|
|
}
|