planetsim/src/render/Viewer.cpp
Jonas Reith cd268167b0 Seed multiple cultures per continent instead of always exactly one
A continent used to always seed exactly one culture at the dawn
(seedCultures(), grouped by regionId), which -- combined with the existing
border-conversion/backfill dynamics -- tended to erode into one
super-dominant culture covering the entire world over a long simulated run.

seedCultures() now splits each continent into 1..cultDawnMaxPerContinent
initial peoples via deterministic farthest-point seeding (a settlement is
assigned to its nearest seed point, giving geographically coherent,
Voronoi-like cultural regions instead of a checkerboard). The target count
scales with the continent's settlement population via the new
cultDawnSettlementsPerCulture config knob, capped by cultDawnMaxPerContinent.
Per-culture ethos/faith/name hashes now fold in Culture.id so multiple
cultures sharing a continent (and therefore the same regionId/bank) get
distinct identities instead of colliding.

This surfaced a real, previously-latent ordering bug: computeTerritory()
(realm/vassalage grouping, which prefers matching by actual culture and only
falls back to same-continent when culture data doesn't exist yet) ran BEFORE
computeCultures() populated per-settlement culture, so on every fresh
recompute realms formed by continent for one full pass before the next
recompute split them correctly. Harmless before this change (one culture per
continent made the two equivalent), but would have left temporarily
multi-cultural realms now. Fixed at the root: split computeCultures() into
refreshSettlementCultures() (the settlement-level seed/backfill/tally slice,
no dependency on `nations`) and had computeTerritory() call it internally
before grouping -- every caller (production and the dozens of existing test
call sites) gets correct behaviour automatically, no call-site changes needed
beyond the two tests whose assertions encoded the old exact one-per-continent
invariant.

test_culture.cpp and test_cultevo.cpp updated to assert the new bounded
(1..cultDawnMaxPerContinent) invariant instead of exact equality, plus that a
single culture never itself spans two continents (still enforced).

Verified live: a single continent now shows six distinct peoples ("the
Thisur", "the Bomebro", "the Draordaes", etc.) instead of one dominant
culture, while realms remain correctly mono-cultural.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
2026-08-30 22:15:12 +02:00

1115 lines
62 KiB
C++

#include "Viewer.hpp"
#include "Picking.hpp" // angBetween (rebuildSub)
#include "Projection.hpp" // EqualEarth (layout)
#include "Toolbar.hpp" // setupToolbarStyle
#include <algorithm>
#include <cmath>
#include <cstring>
#include <fstream>
#include <set>
#include <utility>
namespace {
const char* weatherEventName(const WeatherSystem& ws, const Planet& p) {
double lon = 0.0, lat = 0.0;
dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
if (ws.tropical && ws.strength >= p.cfg.weatherHurricaneStr)
return (lon > -0.5 && lon < 2.4) ? "Typhoon" : "Hurricane";
return ws.tropical ? "Tropical low" : "Low";
}
int eventSeverityForWeather(const WeatherSystem& ws, const Planet& p) {
return (ws.tropical && ws.strength >= p.cfg.weatherHurricaneStr) ? 2 : (ws.tropical ? 1 : 0);
}
bool validFrameWar(const War& w, int settlementCount) {
return w.attacker >= 0 && w.attacker < settlementCount
&& w.defender >= 0 && w.defender < settlementCount
&& w.attacker != w.defender
&& w.battles >= 0
&& std::isfinite(w.warscore);
}
bool sanitizeFrameDiplomacy(std::vector<DiploTie>& ties, int settlementCount) {
std::set<std::pair<int, int>> seen;
for (DiploTie& t : ties) {
if (t.a < 0 || t.a >= settlementCount || t.b < 0 || t.b >= settlementCount || t.a == t.b)
return false;
if (t.a > t.b) std::swap(t.a, t.b);
if (!seen.insert({t.a, t.b}).second) return false;
if (!std::isfinite(t.attitude)) return false;
t.attitude = std::clamp(t.attitude, -1.0, 1.0);
if ((uint8_t)t.kind > (uint8_t)DiploKind::Rival) t.kind = DiploKind::Neutral;
}
return true;
}
bool validateFrameCivState(WeatherSnapshot& w, size_t currentSettlements) {
if (w.settlementPop.size() > currentSettlements) return false;
for (double p : w.settlementPop) if (!std::isfinite(p)) return false;
const int settlementCount = (int)w.settlementPop.size();
if (!w.settlementAllegiance.empty()) {
if ((int)w.settlementAllegiance.size() != settlementCount) return false;
for (int a : w.settlementAllegiance)
if (a < -1 || a >= settlementCount) return false;
}
std::set<std::pair<int, int>> warsSeen;
for (const War& war : w.wars) {
if (!validFrameWar(war, settlementCount)) return false;
if (!warsSeen.insert(std::minmax(war.attacker, war.defender)).second) return false;
}
// v23: per-settlement culture (indices into the frame's culture-list prefix).
if (w.cultureCount > 100000) return false;
if (!w.settlementCulture.empty()) {
if ((int)w.settlementCulture.size() != settlementCount) return false;
for (int c : w.settlementCulture)
if (c < -1 || c >= (int)w.cultureCount) return false;
}
return sanitizeFrameDiplomacy(w.diplomacy, settlementCount);
}
}
bool Viewer::init(int argc, char** argv) {
uint32_t cliSeed = 0; // 0 = no --seed given
for (int a = 1; a < argc; ++a) {
if (!std::strcmp(argv[a], "--seed") && a + 1 < argc)
cliSeed = (uint32_t)std::strtoul(argv[++a], nullptr, 10);
else if (!std::strcmp(argv[a], "--config") && a + 1 < argc)
configPath = argv[++a];
}
SetConfigFlags(FLAG_MSAA_4X_HINT);
InitWindow(screenW, screenH, "Planet Sim - Phase 1: Tectonics");
SetTargetFPS(60);
SetExitKey(KEY_NULL); // raylib's default exit-on-ESC would fight edit mode's Escape-to-cancel
// Layout: left column 70% wide (3D globe 60% h on top, 2D map 40% h below);
// right column 30% wide (cell info 50% h on top, subareas 50% below).
leftW = (int)(screenW * 0.70f); // 1344
rightX = leftW; rightW = screenW - leftW; // 576
rightH = screenH / 2; // 540
// Top-left: 3D globe (render texture, its own aspect).
view3DW = leftW; view3DH = (int)(screenH * 0.60f); // 1344 x 648
rt3d = LoadRenderTexture(view3DW, view3DH);
SetTextureFilter(rt3d.texture, TEXTURE_FILTER_BILINEAR);
// Bottom-left: 2D Equal Earth map, fit (keep aspect) into the 40% strip.
const int mapAreaY = view3DH, mapAreaH = screenH - view3DH; // (0,648) 1344 x 432
int mapH = mapAreaH - 30;
int mapW = (int)(mapH * (EqualEarth::halfWidth() / EqualEarth::halfHeight()));
if (mapW > leftW - 30) { mapW = leftW - 30; mapH = (int)(mapW / (EqualEarth::halfWidth() / EqualEarth::halfHeight())); }
// Left-align the 2D map (was centered) so the freed space at right holds the live sky panel.
const float mapMargin = 16.0f;
mapRect = Rectangle{ mapMargin,
(float)(mapAreaY + (mapAreaH - mapH) / 2),
(float)mapW, (float)mapH };
float liveX = mapRect.x + mapRect.width + 16.0f;
liveInfoRect = Rectangle{ liveX, mapRect.y, (float)leftW - liveX - 8.0f, mapRect.height };
// Right column.
hoverRect = Rectangle{ (float)rightX + 8, 8.0f, (float)rightW - 16, (float)rightH - 16 };
panelRect = Rectangle{ (float)rightX + 8, (float)rightH + 8, (float)rightW - 16, (float)rightH - 16 };
const float panelHeader = 120.0f;
const float gridSide = std::min(panelRect.width - 40.0f, panelRect.height - panelHeader - 56.0f);
gridRect = Rectangle{ panelRect.x + (panelRect.width - gridSide) / 2.0f,
panelRect.y + panelHeader, gridSide, gridSide };
// Buttons (pause + Phase-3 prompt, centered in the 3D viewport).
pauseBtn = Rectangle{ 16.0f, (float)view3DH - 44.0f, 160.0f, 32.0f };
const float pbW = 220.0f, pbH = 42.0f, pbGap = 24.0f;
pbCx = view3DW * 0.5f; pbCy = view3DH * 0.5f;
p3ContinueBtn = Rectangle{ pbCx - pbW - pbGap * 0.5f, pbCy + 8.0f, pbW, pbH };
p3StartBtn = Rectangle{ pbCx + pbGap * 0.5f, pbCy + 8.0f, pbW, pbH };
// Toolbar (raygui sidebar): right edge of the 3D viewport, doesn't overlap the top-center
// view-mode label, the phase-3 modal, or the bottom-left pause button.
toolbarRect = Rectangle{ (float)view3DW - 300.0f, 0.0f, 300.0f, (float)view3DH };
toolbarToggleBtn = Rectangle{ (float)view3DW - 92.0f, 4.0f, 84.0f, 26.0f };
setupToolbarStyle();
cfg.subdivisions = 5;
if (!loadConfig(configPath, cfg)) saveConfig(configPath, cfg); // load, or create a default
if (cliSeed != 0) cfg.seed = cliSeed; // CLI --seed overrides config
std::string cfgErr = validateConfig(cfg);
if (!cfgErr.empty()) cfg = PlanetConfig{}; // revert to safe defaults
planet.generate(cfg);
cam.position = {0, 0, 6}; cam.target = {0, 0, 0}; cam.up = {0, 1, 0};
cam.fovy = 45; cam.projection = CAMERA_PERSPECTIVE;
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
graticule = buildGraticule();
buildMap2D(planet, mapRect, map2D);
phase3PromptAt = planet.cfg.phase3AfterMy;
refreshView(); // colour the freshly generated (flat) world
return true;
}
void Viewer::rebuildSub() {
subgrids.clear();
if (selectedCell < 0) return;
subgrids.push_back(planet.makeSubGrid(selectedCell, subRes));
const Cell& c = planet.cells[selectedCell];
for (int nb : c.neighbors) subgrids.push_back(planet.makeSubGrid(nb, subRes));
double ma = 0.0;
for (int nb : c.neighbors) ma += angBetween(c.unit, planet.cells[nb].unit);
ma /= std::max<size_t>(1, c.neighbors.size());
selectedThresh = ma * 1.4;
}
void Viewer::selectCell(int idx) {
if (idx < 0) return;
if (idx == selectedCell) { selectedCell = -1; subgrids.clear(); return; }
selectedCell = idx; rebuildSub();
if (editMode) { // switching tiles mid-edit cancels any open picker / typed entry (keeps the tab)
editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None;
}
}
void Viewer::exitEditMode() { editPanel = EditPanelState{}; }
// Recolor the mesh + refresh the elevation range, read straight from cells.
void Viewer::recolor() {
double maxAge = 1.0; for (const auto& c : planet.cells) maxAge = std::max(maxAge, c.geoAge);
const std::vector<double>& temp = planet.temperature();
const std::vector<double>& summer = planet.summerTemp();
const std::vector<double>& winter = planet.winterTemp();
const std::vector<double>& moist = planet.moisture(); // 0..1, already robustly normalized
const std::vector<double>& flora = planet.floraDensity();
const std::vector<double>& fauna = planet.faunaDensity();
const std::vector<double>& funga = planet.fungaDensity();
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();
const std::vector<int>& ccult = planet.cellCulture();
const std::vector<double>& cwealth = planet.cellWealth();
double wealthMax = 1e-6; // normalise the wealth heat map to the current max
for (double w : cwealth) if (w > wealthMax) wealthMax = w;
vcolors.resize(planet.cells.size());
for (size_t i = 0; i < planet.cells.size(); ++i) {
switch (mode) {
case ColorMode::Plate: {
int pid = planet.cells[i].plateId;
vcolors[i] = (pid >= 0 && pid < (int)planet.plates.size() && planet.plates[pid].baby)
? Color{70, 80, 95, 255} // young spreading-ridge crust
: plateColor(pid);
break;
}
case ColorMode::Age: vcolors[i] = ageColor(planet.cells[i].geoAge, maxAge); break;
case ColorMode::Crust: vcolors[i] = crustColor(planet.cells[i].oceanic); break;
case ColorMode::Biome: vcolors[i] = biomeColor(planet.cells[i].biome); break;
case ColorMode::Temperature: vcolors[i] = temp.empty() ? Color{90,90,90,255} : tempColor(temp[i]); break;
case ColorMode::TempSummer: vcolors[i] = summer.empty()? Color{90,90,90,255} : tempColor(summer[i]); break;
case ColorMode::TempWinter: vcolors[i] = winter.empty()? Color{90,90,90,255} : tempColor(winter[i]); break;
case ColorMode::Seasonality: vcolors[i] = (summer.empty()||winter.empty()) ? Color{90,90,90,255}
: seasonColor(summer[i] - winter[i]); break;
case ColorMode::Precip: vcolors[i] = moist.empty() ? Color{90,90,90,255} : precipColor(moist[i]); break;
case ColorMode::FloraDensity: vcolors[i] = flora.empty() ? Color{90,90,90,255}
: (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFloraColor(flora[i]) : floraColor(flora[i])); break;
case ColorMode::FaunaDensity: vcolors[i] = fauna.empty() ? Color{90,90,90,255}
: (planet.cells[i].elevation <= planet.cfg.seaLevel ? marineFaunaColor(fauna[i]) : faunaColor(fauna[i])); break;
case ColorMode::FungaDensity: vcolors[i] = funga.empty() ? Color{90,90,90,255} : fungaColor(funga[i]); break;
case ColorMode::Ecoregion: {
int ei = (i < ecoCell.size()) ? ecoCell[i] : -1;
double prod = (ei >= 0 && ei < (int)eco.size())
? std::max({ eco[ei].floraProductivity, eco[ei].faunaProductivity, eco[ei].fungaProductivity })
: 0.0;
vcolors[i] = (ei >= 0 && ei < (int)eco.size())
? ecoregionColor(ei, eco[ei].biome, prod) : Color{55, 58, 64, 255};
break;
}
case ColorMode::Habitability:
vcolors[i] = (i < hab.size() && planet.cells[i].elevation > planet.cfg.seaLevel
&& 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 < 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;
}
case ColorMode::Culture: {
int ci = (i < ccult.size()) ? ccult[i] : -1;
if (ci >= 0) vcolors[i] = cultureColor(ci); // owned: culture tint
else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // uncultured land vs sea
? Color{60, 64, 58, 255} : Color{26, 34, 52, 255};
break;
}
case ColorMode::Wealth: {
double w = (i < cwealth.size()) ? cwealth[i] : 0.0;
if (w > 0.0) vcolors[i] = wealthColor(w / wealthMax); // owned land: trade prosperity
else vcolors[i] = (planet.cells[i].elevation > planet.cfg.seaLevel) // wilderness land vs sea
? Color{40, 42, 46, 255} : Color{22, 30, 44, 255};
break;
}
default: vcolors[i] = elevationColor(planet.cells[i].elevation, planet.cfg.seaLevel);
}
}
// Phase 3: shade filled basins above sea level as inland water (lakes).
const std::vector<double>& lk = planet.lakeDepth();
if (phase3 && !lk.empty())
for (size_t i = 0; i < planet.cells.size(); ++i)
if (lk[i] > 20.0 && planet.cells[i].elevation > planet.cfg.seaLevel)
vcolors[i] = lakeColor();
minE = planet.minElevation(); maxE = planet.maxElevation();
}
// Live World: from the sim's insolation + live-temperature fields, build the per-cell
// day/night brightness (illum) and the shaded draw colours (base colour -> snow/ice tint ->
// day/night dim). Cheap O(n); called every frame while in Live World.
void Viewer::rebuildLiveOverlay() {
const size_t n = planet.cells.size();
const std::vector<double>& sun = planet.insolation();
const std::vector<double>& lt = planet.liveTemp();
const double sea = planet.cfg.seaLevel;
const double snowT = planet.cfg.snowTemp;
const double iceT = planet.cfg.seaIceTemp;
const float nightFloor = 0.18f; // night side dim (not black) so colours read
illum.assign(n, 1.0f);
shadedColors.resize(n);
auto smoothstep = [](double e0, double e1, double x) {
double t = (e1 > e0) ? (x - e0) / (e1 - e0) : 0.0;
t = t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
return t * t * (3.0 - 2.0 * t);
};
// Solar eclipse: a moon roughly between the sun and the planet (its model-space direction
// near the sun's) casts a shadow around the sub-solar point. Strength ramps with alignment.
const Vec3 sd{ sunDir.x, sunDir.y, sunDir.z };
const double eclipseReach = 0.13; // rad: how close a moon must be to the sun to eclipse
const double umbra = 0.10; // rad: angular radius of the shadow spot
double eclipseStrength = 0.0;
for (const auto& md : moonDirs) {
double d = std::acos(std::clamp((double)(md.x*sd.x + md.y*sd.y + md.z*sd.z), -1.0, 1.0));
if (d < eclipseReach) eclipseStrength = std::max(eclipseStrength, 1.0 - d / eclipseReach);
}
auto blend = [](unsigned char c, unsigned char to, double a) {
return (unsigned char)(c + (to - c) * a);
};
for (size_t i = 0; i < n; ++i) {
// Day/night: soft sunrise band over the clamped cosine incidence.
float f = nightFloor;
if (!sun.empty()) f = nightFloor + (1.0f - nightFloor) * (float)smoothstep(0.0, 0.12, sun[i]);
// Eclipse shadow: darken cells near the sub-solar point while a moon transits the sun.
if (eclipseStrength > 0.0 && !sun.empty()) {
double dd = std::acos(std::clamp(planet.cells[i].unit.x*sd.x + planet.cells[i].unit.y*sd.y
+ planet.cells[i].unit.z*sd.z, -1.0, 1.0));
double sh = eclipseStrength * std::exp(-(dd / umbra) * (dd / umbra));
f *= (float)std::max(0.10, 1.0 - 0.85 * sh);
}
illum[i] = f;
Color c = vcolors[i];
// Snow on cold land, sea ice on cold ocean (live seasonal temperature).
if (!lt.empty()) {
double e = planet.cells[i].elevation;
if (e > sea) {
double a = (snowT - lt[i]) / 8.0; // fully snow ~8 C below freezing
if (a > 0.0) { a = a > 0.85 ? 0.85 : a;
c = Color{ blend(c.r, 242, a), blend(c.g, 246, a), blend(c.b, 250, a), 255 }; }
} else {
double a = (iceT - lt[i]) / 6.0; // sea ice
if (a > 0.0) { a = a > 0.9 ? 0.9 : a;
c = Color{ blend(c.r, 212, a), blend(c.g, 226, a), blend(c.b, 236, a), 255 }; }
}
}
// Day/night dimming over the (possibly snow-tinted) colour.
if (dayNightOn) c = Color{ (unsigned char)(c.r * f), (unsigned char)(c.g * f), (unsigned char)(c.b * f), 255 };
shadedColors[i] = c;
}
}
void Viewer::refreshView() {
if (phase3) planet.computeHydrology(); // refresh lakes/rivers for the view
planet.computeClimate(); // temperature + precipitation fields
planet.classifyBiomes(); // keep cell.biome current (reads the climate)
planet.computeBiotaDensity(); // flora/fauna/funga density (population is on-demand)
recolor();
if (settled) { // Phase 2: plates moved -> boundaries moved
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
}
if (phase3) buildRivers(planet, riverR, rivers, bigRivers);
buildCoastline(planet, riverR, coast, coastOcean); // land/ocean boundary (for tide lines)
buildCurrents(planet, driftR, currentSegs, currentCols); // ocean current arrows (warm/cold)
if (selectedCell >= 0) rebuildSub();
}
void Viewer::regenWorld() { // after generate(): geometry changed
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear();
editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None;
mapZoom = 1.0; mapPanX = 0.0; mapPanY = 0.0; // drop any 2D-map zoom/pan from the old world
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 on a reseed or they keep drawing over the new world (their draw toggles are
// derived from `mode`, reset right below, so nothing separate needs resetting here).
nationBorders.clear(); cultureBorders.clear(); warFrontier.clear();
allyLinks.clear(); rivalLinks.clear(); tradeSea.clear(); tradeLand.clear();
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();
elapsedMy = 0.0; dtMy = 0.0; driftAccum = 0.0;
refreshView();
}
void Viewer::regen() { planet.generate(cfg); regenWorld(); }
void Viewer::stepOnce() { // one tick + settle bookkeeping
maxChange = planet.step(); ++stepCount;
if (maxChange < settleThresh) { if (++settleRun >= settleNeed) settled = true; }
else settleRun = 0;
}
void Viewer::pauseAction() { paused = !paused; } // pause/resume forming or drift
void Viewer::setStatus(const std::string& m) { statusMsg = m; statusUntil = GetTime() + 3.0; }
void Viewer::appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId,
const std::string& title, const std::string& detail) {
if (cell < 0 || cell >= (int)planet.cells.size()) return;
WorldEvent e;
e.id = nextEventId++;
e.kind = kind;
e.severity = severity;
e.timeHours = timeHours;
e.cell = cell;
e.sourceId = sourceId;
e.title = title;
e.detail = detail;
events.push_back(std::move(e));
if ((int)events.size() > EVENT_LOG_MAX)
events.erase(events.begin(), events.begin() + ((int)events.size() - EVENT_LOG_MAX));
}
void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements,
const std::vector<double>& beforePlague) {
auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* {
for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws;
return nullptr;
};
for (const WeatherSystem& ws : planet.storms()) {
const WeatherSystem* old = beforeStorm(ws.id);
int cell = nearestCell(planet, Vec3{ws.pos.x, ws.pos.y, ws.pos.z});
double lon = 0.0, lat = 0.0; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
std::string loc = std::string(TextFormat("%+.0f lat, %+.0f lon", lat * 180.0 / M_PI, lon * 180.0 / M_PI));
if (!old) {
const char* name = weatherEventName(ws, planet);
appendEvent(1, (uint8_t)eventSeverityForWeather(ws, planet), liveTime, cell, ws.id,
std::string(name) + " formed",
std::string(TextFormat("%.0f%% strength, %s", ws.strength * 100.0, loc.c_str())));
} else if (ws.tropical && old->strength < planet.cfg.weatherHurricaneStr
&& ws.strength >= planet.cfg.weatherHurricaneStr) {
const char* name = weatherEventName(ws, planet);
appendEvent(1, 2, liveTime, cell, ws.id,
std::string(name) + " intensified",
std::string(TextFormat("%.0f%% strength, %s", ws.strength * 100.0, loc.c_str())));
}
}
auto beforeVolcano = [&](uint32_t id) -> const Volcano* {
for (const Volcano& v : beforeVolcanoes) if (v.id == id) return &v;
return nullptr;
};
for (const Volcano& v : planet.volcanoes) {
const Volcano* old = beforeVolcano(v.id);
if (!old || v.cell < 0 || v.cell >= (int)planet.cells.size()) continue;
const char* kind = v.kind == 0 ? "Ridge volcano" : v.kind == 1 ? "Border volcano" : "Hotspot volcano";
double oldElev = old->baseElev + old->built;
double newElev = v.baseElev + v.built;
if (old->submarine && oldElev <= planet.cfg.seaLevel && newElev > planet.cfg.seaLevel) {
// Add the new land to the atlas: join an adjacent landmass or mint a fresh Island name.
std::string land = planet.nameNewLand(v.cell);
std::string title = land.empty() ? std::string("Volcanic island formed")
: land + " formed";
appendEvent(2, 1, liveTime, v.cell, v.id, title,
std::string(TextFormat("%s breached sea level (+%.0f m built)", kind, v.built)));
}
if (old->phase != 1 && v.phase == 1) {
appendEvent(2, 1, liveTime, v.cell, v.id, "Volcano went dormant",
std::string(TextFormat("%s, +%.0f m built", kind, v.built)));
}
if (old->phase == 1 && v.phase == 0 && v.ashTimer > 0.0) {
appendEvent(2, 2, liveTime, v.cell, v.id, "Volcano erupted",
std::string(TextFormat("%s exploded, +%.0f m remains", kind, v.built)));
}
}
// Civilization (kind=3): a settlement crossing a tier boundary or being abandoned / revived. The
// set is fixed, so compare by index against the before-snapshot.
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
auto popLine = [&](const Settlement& st) -> std::string {
double p = st.population;
if (p >= 1.0e6) return std::string(TextFormat("pop %.1fM", p / 1.0e6));
if (p >= 1.0e3) return std::string(TextFormat("pop %.0fk", p / 1.0e3));
return std::string(TextFormat("pop %.0f", p));
};
for (size_t k = 0; k < planet.settlements.size() && k < beforeSettlements.size(); ++k) {
const Settlement& s = planet.settlements[k];
const Settlement& o = beforeSettlements[k];
SettleTier tb = settleTierOf(o.population, townP, cityP), ta = settleTierOf(s.population, townP, cityP);
bool aliveB = o.population >= abP, aliveA = s.population >= abP;
double pl = (k < planet.settlementPlague().size()) ? planet.settlementPlague()[k] : 0.0;
// Plague onset: an epidemic wave started in this settlement this frame.
if (aliveA && pl > 0.02 && (k >= beforePlague.size() || beforePlague[k] <= 1e-9))
appendEvent(3, 2, liveTime, s.cell, s.id, "Plague ravages " + s.name,
std::string(TextFormat("%.0f%%/yr dying, ", pl * 100.0)) + popLine(s));
if (aliveB && !aliveA)
appendEvent(3, 2, liveTime, s.cell, s.id, s.name + " was abandoned", popLine(s));
else if (!aliveB && aliveA)
appendEvent(3, 1, liveTime, s.cell, s.id, s.name + " was resettled", popLine(s));
else if (aliveA && (int)ta > (int)tb)
appendEvent(3, 1, liveTime, s.cell, s.id,
s.name + " grew into a " + settleTierName(ta), popLine(s));
else if (aliveA && (int)ta < (int)tb) { // tier DOWN -- attribute the cause
const char* stormName = nullptr;
for (const auto& ws : planet.storms()) {
double ang = std::acos(std::clamp(planet.cells[s.cell].unit.dot(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}), -1.0, 1.0));
if (ang < ws.radius && ws.strength > 0.35) { stormName = weatherEventName(ws, planet); break; }
}
double dr = (k < planet.settlementDrought().size()) ? planet.settlementDrought()[k] : 0.0;
std::string title, detail = popLine(s);
if (stormName) title = std::string(stormName) + " devastates " + s.name;
else if (pl > 0.02) { title = std::string("Plague shrinks ") + s.name + " to a " + settleTierName(ta);
detail = std::string(TextFormat("%.0f%%/yr dying, ", pl * 100.0)) + popLine(s); }
else if (dr > 0.25) { title = std::string("Famine shrinks ") + s.name + " to a " + settleTierName(ta);
detail = std::string(TextFormat("drought %.0f%%, ", dr * 100.0)) + popLine(s); }
else title = s.name + " declined to a " + settleTierName(ta);
appendEvent(3, 2, liveTime, s.cell, s.id, title, detail);
}
}
}
// 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;
rebuildSub();
followId = 0;
Vec3 wd = rotateZ(planet.cells[idx].unit, planet.cfg.axialTilt);
camPitch = std::clamp((float)std::asin(std::clamp(wd.y, -1.0, 1.0)), -1.5f, 1.5f);
camYaw = (float)std::atan2(wd.x, wd.z);
cam.position = { camDist * cosf(camPitch) * sinf(camYaw),
camDist * sinf(camPitch),
camDist * cosf(camPitch) * cosf(camYaw) };
double lon = map2D.lon.empty() ? 0.0 : map2D.lon[idx];
double lat = map2D.lat.empty() ? 0.0 : map2D.lat[idx];
mapLon = wrapPi(-lon);
if (mapZoom <= 1.0001) {
mapPanX = mapPanY = 0.0;
} else {
double x = 0.0, y = 0.0;
EqualEarth::forward(0.0, lat, x, y);
double hh = EqualEarth::halfHeight();
double h = mapRect.height * mapZoom;
double targetY = mapRect.y + (0.5 - y / hh * 0.5) * h + (mapRect.height - h) * 0.5;
mapPanY = std::clamp(mapRect.y + mapRect.height * 0.5 - targetY,
-(h - mapRect.height) * 0.5, (h - mapRect.height) * 0.5);
double w = mapRect.width * mapZoom;
mapPanX = std::clamp(0.0, -(w - mapRect.width) * 0.5, (w - mapRect.width) * 0.5);
}
if (!status.empty()) setStatus(status);
}
// F5: write seed + config + full planet state. F9: read it back and resume.
void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary);
if (!os) { setStatus("Save failed"); return; }
uint32_t ver = SAVE_VERSION; uint8_t st = settled ? 1 : 0; uint8_t p3 = phase3 ? 1 : 0;
uint8_t lw = liveWorld ? 1 : 0;
os.write("PLSV", 4);
os.write(reinterpret_cast<const char*>(&ver), sizeof ver);
os.write(reinterpret_cast<const char*>(&elapsedMy), sizeof elapsedMy);
os.write(reinterpret_cast<const char*>(&st), sizeof st);
os.write(reinterpret_cast<const char*>(&driftRate), sizeof driftRate);
os.write(reinterpret_cast<const char*>(&p3), sizeof p3); // v3: Phase-3 flag
os.write(reinterpret_cast<const char*>(&lw), sizeof lw); // v8: Live World flag
os.write(reinterpret_cast<const char*>(&liveTime), sizeof liveTime); // v8: live clock (hours)
os.write(reinterpret_cast<const char*>(&liveRate), sizeof liveRate); // v13: live clock rate
planet.writeState(os);
// v12: persist the most recent step-back frames so a load can rewind storms past the moment.
// v15: frames also carry stateful volcano agents + their RNG.
auto wD = [&](const std::vector<double>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(double))); };
auto wV = [&](const std::vector<Volcano>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(Volcano))); };
auto wS = [&](const std::string& s){ uint64_t m = s.size(); os.write((char*)&m, 8); if (m) os.write(s.data(), (std::streamsize)m); };
uint32_t hn = (uint32_t)std::min<size_t>(wxUndo.size(), (size_t)wxSaveMax);
os.write((char*)&hn, 4);
for (size_t i = wxUndo.size() - hn; i < wxUndo.size(); ++i) {
const WxFrame& f = wxUndo[i];
os.write((char*)&f.t, 8);
wD(f.w.humidity); wD(f.w.cloud); wD(f.w.rain);
uint64_t sc = f.w.storms.size(); os.write((char*)&sc, 8);
if (sc) os.write((const char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
os.write((char*)&f.w.rng, 4); os.write((char*)&f.w.nextId, 4);
wV(f.w.volcanoes);
os.write((char*)&f.w.volRng, 4);
wD(f.w.settlementPop); // v20: per-frame settlement populations
// v21: per-frame conflict state (allegiance + wars + war RNG), so a load can rewind conquests.
{ uint64_t m = f.w.settlementAllegiance.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.settlementAllegiance.data(), (std::streamsize)(m * sizeof(int))); }
{ uint64_t m = f.w.wars.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.wars.data(), (std::streamsize)(m * sizeof(War))); }
os.write((char*)&f.w.warRng, 4); os.write((char*)&f.w.warNextId, 4);
// v22: per-frame diplomacy, so a load can rewind alliances/rivalries past the saved moment.
{ uint64_t m = f.w.diplomacy.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
// v23: per-frame culture state (per-settlement culture + culture-list length), so a load can
// rewind conversions/assimilations/schisms past the saved moment.
{ uint64_t m = f.w.settlementCulture.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); }
os.write((char*)&f.w.cultureCount, 4); os.write((char*)&f.w.cultureNextId, 4);
}
// v16: persistent world event journal, separate from step-back history.
uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX);
os.write((char*)&nextEventId, 4);
os.write((char*)&en, 4);
for (size_t i = events.size() - en; i < events.size(); ++i) {
const WorldEvent& e = events[i];
os.write((char*)&e.id, 4);
os.write((char*)&e.kind, 1);
os.write((char*)&e.severity, 1);
os.write((char*)&e.timeHours, 8);
os.write((char*)&e.cell, 4);
os.write((char*)&e.sourceId, 4);
wS(e.title); wS(e.detail);
}
setStatus(os ? std::string("Saved ") + path : "Save failed");
}
void Viewer::loadGame(const char* path) {
std::ifstream is(path, std::ios::binary);
if (!is) { setStatus(std::string("No ") + path); return; }
char magic[4] = {0}; uint32_t ver = 0; double em = 0; uint8_t st = 0; double dr = 4.0; uint8_t p3 = 0;
uint8_t lw = 0; double lh = 0.0; double lr = 1.0;
is.read(magic, 4);
is.read(reinterpret_cast<char*>(&ver), sizeof ver);
is.read(reinterpret_cast<char*>(&em), sizeof em);
is.read(reinterpret_cast<char*>(&st), sizeof st);
if (ver >= 2) is.read(reinterpret_cast<char*>(&dr), sizeof dr);
if (ver >= 3) is.read(reinterpret_cast<char*>(&p3), sizeof p3);
if (ver >= 8) { is.read(reinterpret_cast<char*>(&lw), sizeof lw);
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22, ver >= 23, ver >= 24)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy, v23 cultures, v24 edit mode
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
phase3 = (p3 != 0); phase3Prompt = false;
phase3PromptAt = phase3 ? elapsedMy : (elapsedMy + planet.cfg.phase3AfterMy);
driftRate = dr;
liveWorld = (lw != 0); liveTime = lh; liveRate = std::clamp(lr, 0.25, liveRateMax()); // v8/v13: resume the Live World clock
settleRun = settleNeed; // keep the settled latch consistent
dtMy = settled ? planet.cflDtMy() : 0.0;
driftAccum = 0.0; formAccum = 0.0;
wxUndo.clear(); followId = 0; // drop stale step-back history / follow target
bool skippedHistory = false;
if (ver >= 12 && ver < 15) {
skippedHistory = true; // old frames lack stateful volcanoes; do not restore them
}
if (ver >= 15) { // v15: restore saved step-back frames, including volcano state
bool historyOk = true;
const uint64_t cellCount = planet.cells.size();
auto rD = [&](std::vector<double>& v){
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || (m != 0 && m != cellCount)) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
auto rV = [&](std::vector<Volcano>& v){
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 100000) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(Volcano)));
if (!is) historyOk = false;
};
auto rP = [&](std::vector<double>& v){ // settlement populations (not cell-sized)
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
uint32_t hn = 0; is.read((char*)&hn, 4);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
for (uint32_t k = 0; k < hn && is; ++k) {
WxFrame f; is.read((char*)&f.t, 8);
rD(f.w.humidity); rD(f.w.cloud); rD(f.w.rain);
uint64_t sc = 0; is.read((char*)&sc, 8);
if (!is || sc > (uint64_t)planet.cfg.weatherSystemMax) { historyOk = false; break; }
f.w.storms.resize((size_t)sc);
if (sc) is.read((char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4);
rV(f.w.volcanoes);
is.read((char*)&f.w.volRng, 4);
if (ver >= 20) rP(f.w.settlementPop); // v20: per-frame settlement populations
if (ver >= 21) { // v21: per-frame conflict state
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.settlementAllegiance.resize((size_t)m); if (m) is.read((char*)f.w.settlementAllegiance.data(), (std::streamsize)(m * sizeof(int))); }
uint64_t nw = 0; is.read((char*)&nw, 8);
if (!is || nw > 100000) historyOk = false;
else { f.w.wars.resize((size_t)nw); if (nw) is.read((char*)f.w.wars.data(), (std::streamsize)(nw * sizeof(War))); }
is.read((char*)&f.w.warRng, 4); is.read((char*)&f.w.warNextId, 4);
if (!is) historyOk = false;
}
if (ver >= 22) { // v22: per-frame diplomacy
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.diplomacy.resize((size_t)m); if (m) is.read((char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
if (!is) historyOk = false;
}
if (ver >= 23) { // v23: per-frame culture state
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.settlementCulture.resize((size_t)m); if (m) is.read((char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); }
is.read((char*)&f.w.cultureCount, 4); is.read((char*)&f.w.cultureNextId, 4);
if (!is) historyOk = false;
}
auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
historyOk = false;
for (const WeatherSystem& ws : f.w.storms)
if (!std::isfinite(ws.pos.x) || !std::isfinite(ws.pos.y) || !std::isfinite(ws.pos.z)
|| std::fabs(ws.pos.length() - 1.0) > 1e-6
|| !std::isfinite(ws.strength) || ws.strength < 0.0 || ws.strength > 1.0
|| !std::isfinite(ws.radius) || ws.radius <= 0.0
|| !std::isfinite(ws.age) || !std::isfinite(ws.life)
|| !std::isfinite(ws.spin)) historyOk = false;
for (const Volcano& v : f.w.volcanoes)
if (v.cell < 0 || v.cell >= (int)planet.cells.size() || v.phase > 1
|| !std::isfinite(v.activity) || !std::isfinite(v.baseElev)
|| !std::isfinite(v.built) || !std::isfinite(v.timer)
|| !std::isfinite(v.ashTimer) || !std::isfinite(v.ashCarry)) historyOk = false;
if (!validateFrameCivState(f.w, planet.settlements.size())) historyOk = false;
if (historyOk) wxUndo.push_back(std::move(f));
}
if (!historyOk) { wxUndo.clear(); skippedHistory = true; }
}
events.clear(); nextEventId = 1; liveInfoTab = 0; eventRowRects.clear(); eventRowIndices.clear();
if (ver >= 16) {
bool eventsOk = true;
auto rS = [&](std::string& s) {
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 4096) { eventsOk = false; s.clear(); return; }
s.assign((size_t)m, '\0');
if (m) is.read(&s[0], (std::streamsize)m);
if (!is) eventsOk = false;
};
uint32_t en = 0;
is.read((char*)&nextEventId, 4);
is.read((char*)&en, 4);
if (!is || en > (uint32_t)EVENT_LOG_MAX) eventsOk = false;
for (uint32_t k = 0; k < en && is; ++k) {
WorldEvent e;
is.read((char*)&e.id, 4);
is.read((char*)&e.kind, 1);
is.read((char*)&e.severity, 1);
is.read((char*)&e.timeHours, 8);
is.read((char*)&e.cell, 4);
is.read((char*)&e.sourceId, 4);
rS(e.title); rS(e.detail);
if (e.cell < 0 || e.cell >= (int)planet.cells.size()
|| !std::isfinite(e.timeHours) || e.kind == 0 || e.kind > 32 || e.severity > 3)
eventsOk = false;
if (eventsOk) events.push_back(std::move(e));
}
if (!eventsOk) { events.clear(); nextEventId = 1; skippedHistory = true; }
}
paused = true; selectedCell = -1; subgrids.clear();
// Pre-v14 save already in Live World: it has no volcano block, so place a set now (v14+ saves
// restore their own). A non-live save places them when the user first presses W.
if (liveWorld && planet.volcanoes.empty()) planet.placeVolcanoes(liveTime);
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);
}
// Advance the simulation this frame: Phase-1 forming (paced ticks toward
// equilibrium), or Phase-2 drift / Phase-3 drift+hydrology at a finer dt.
void Viewer::stepSim() {
if (liveWorld) {
// --- Live World: advance the slow clock; geology is frozen --------
double dtH = (!paused) ? liveRate * GetFrameTime() : 0.0; // simulated hours this frame
if (dtH > 0.0 && (wxUndo.empty() || liveTime - wxUndo.back().t >= liveRate - 1e-9))
wxPushSnapshot(); // throttled history during a continuous run (~1 snapshot/sec)
liveAdvance(dtH, dtH);
return;
}
if (!paused && !settled) {
// --- Phase 1: forming, paced ticks toward equilibrium -------------
formAccum += GetFrameTime() * formRate;
int budget = 0;
while (formAccum >= 1.0 && budget < 2000) {
stepOnce(); formAccum -= 1.0; ++budget;
if (settled) break;
}
if (settled) { dtMy = planet.cflDtMy(); planet.drifting = true; } // entering Phase 2
refreshView(); // live update so you watch the terrain rise
} else if (!paused && settled) {
// --- Phase 2 drift (and Phase 3 = drift + hydrology at a finer dt) --
// Drift never stops; Phase 3 just uses a smaller timestep so each step
// advances fewer My (more steps before plates visibly move) while
// rivers/lakes/fluvial erosion resolve.
double dt = planet.cflDtMy() * (phase3 ? planet.cfg.phase3DtScale : 1.0);
dtMy = dt;
driftAccum += driftRate * GetFrameTime(); // accumulate across frames
const int guardMax = phase3 ? 60 : 500; // Phase-3 ticks are heavier
int guard = 0; bool advanced = false;
while (driftAccum >= dt && guard < guardMax) {
planet.advect(dt); planet.step(); planet.erode(dt);
if (phase3) planet.hydrology(dt);
elapsedMy += dt; driftAccum -= dt; ++guard; advanced = true;
// Timed Phase-3 invitation: pause + prompt once we cross the mark.
if (!phase3 && elapsedMy >= phase3PromptAt) { phase3Prompt = true; paused = true; break; }
}
if (driftAccum > 2.0 * dt) driftAccum = 2.0 * dt; // drop backlog (don't runaway)
if (advanced) refreshView(); // live: watch the world evolve
}
}
// Advance the Live World clock by dtClock hours and recompute the derived fields. Weather is an
// integrated, non-reversible path, so it advances by dtWeather (0 = hold, used for a backward
// step which still rewinds the deterministic sky: day/night, tides, seasons, moon phases).
void Viewer::liveAdvance(double dtClock, double dtWeather) {
liveTime = std::max(0.0, liveTime + dtClock);
double days = liveTime / planet.cfg.dayLengthHours;
double dayOfYear01 = days / planet.cfg.yearLengthDays; dayOfYear01 -= std::floor(dayOfYear01);
double timeOfDay01 = days - std::floor(days);
planet.computeInsolation(dayOfYear01, timeOfDay01);
planet.computeLiveSeason(dayOfYear01);
planet.computeTides(dayOfYear01, timeOfDay01, days);
Vec3 s = planet.sunDirection(dayOfYear01, timeOfDay01);
sunDir = Vector3{ (float)s.x, (float)s.y, (float)s.z };
moonDirs.clear(); moonNormals.clear();
for (int m = 0; m < (int)planet.getMoons().size(); ++m) {
Vec3 md = planet.moonDirection(m, timeOfDay01, days);
Vec3 mn = planet.moonOrbitNormal(m, timeOfDay01);
moonDirs.push_back(Vector3{ (float)md.x, (float)md.y, (float)md.z });
moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z });
}
std::vector<WeatherSystem> beforeStorms;
std::vector<Volcano> beforeVolcanoes;
std::vector<Settlement> beforeSettlements;
std::vector<double> beforePlague;
if (dtWeather > 0.0) {
beforeStorms = planet.storms();
beforeVolcanoes = planet.volcanoes;
beforeSettlements = planet.settlements;
beforePlague = planet.settlementPlague();
}
planet.stepWeather(dtWeather);
// Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// reasserts restored terrain/biome state without advancing the lifecycle.
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, beforePlague);
// Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines).
// Wars (civ Step 5) run on the same yearly tick: stepConflict mutates allegiance/populations first,
// then territory recomputes so borders move as cities change hands.
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>{};
if (dtWeather > 0.0) { // advancing: simulate each crossed year (not on a step back)
// At high clock rates a frame can span multiple years -- catch up one year at a time so
// wars/territory aren't skipped (bounded: an extreme jump only simulates the most recent).
const long maxCatch = 12;
long firstYear = std::max(lastTerritoryYear + 1, year - maxCatch + 1);
for (long y = firstYear; y <= year; ++y) {
ConflictUpdate wu = planet.stepConflict(y);
for (const WarEvent& e : wu.events)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
// Step 8 after conflict (assimilation reads this year's fresh allegiance) and before
// colonization (a colony is stamped with its founder's possibly-just-changed culture).
for (const WarEvent& e : planet.stepCulture(y)) // cultural evolution
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
}
}
rebuildTerritory(); // final recompute (also refreshes a backward year-cross)
if (dtWeather > 0.0) detectNationEvents(beforeNations);
territoryChanged = true;
}
}
if (vu.breach) refreshView();
else if (vu.recolor || cu.recolor
|| (territoryChanged && (mode == ColorMode::Territory || mode == ColorMode::Culture || mode == ColorMode::Wealth))) recolor();
rebuildLiveOverlay();
}
// Recompute realms/territory + cultures from the (derived) settlement set and rebuild the border
// segments (political + cultural). Cultures depend on territory, so compute them right after.
void Viewer::rebuildTerritory() {
planet.computeTerritory(); // refreshes per-settlement culture itself before grouping into realms
planet.computeCultures();
planet.computeTrade(); // civ Step 7: trade links + prosperity (derived)
buildNationBorders(planet, borderR, nationBorders);
buildCultureBorders(planet, borderR, cultureBorders);
buildWarFrontier(planet, borderR + 0.001f, warFrontier); // civ Step 5: current war fronts (drawn red)
buildDiploLinks(planet, borderR, allyLinks, rivalLinks); // civ Step 6: alliance/rivalry arcs
buildTradeRoutes(planet, borderR, tradeSea, tradeLand); // civ Step 7: trade routes
double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
lastTerritoryYear = (long)std::floor(liveTime / yearHours);
}
// ---- Actions shared by keyboard shortcuts (ViewerInput.cpp) and the toolbar (Toolbar.cpp) -----
// Each extracted verbatim from its key handler's body (guard moved inside), so a keypress and the
// matching toolbar widget can never diverge in behaviour -- see Viewer.hpp for the full list.
void Viewer::toggleTerritoryView() { // P: territory/realms colour view + political borders
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.nationsBuilt() || (int)planet.cellNation().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Territory) ? ColorMode::Biome : ColorMode::Territory;
recolor();
setStatus(mode == ColorMode::Territory ? "Territory / realms on" : "Territory off");
}
void Viewer::toggleCultureView() { // X: culture/faiths colour view + cultural borders
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.culturesBuilt() || (int)planet.cellCulture().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Culture) ? ColorMode::Biome : ColorMode::Culture;
recolor();
setStatus(mode == ColorMode::Culture ? "Cultures / faiths on" : "Cultures off");
}
void Viewer::toggleTradeView() { // Z: wealth/trade colour view + trade routes
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.tradeBuilt() || (int)planet.cellWealth().size() != (int)planet.cells.size()) rebuildTerritory();
mode = (mode == ColorMode::Wealth) ? ColorMode::Biome : ColorMode::Wealth;
recolor();
setStatus(mode == ColorMode::Wealth ? "Wealth / trade on" : "Wealth off");
}
void Viewer::toggleRealmOverlay() { // Q: realm borders + names (+culture) over any colour mode
if (!settled) return;
if (!planet.settlementsPlaced()) { setStatus("Press U for the dawn of civilization first"); return; }
if (!planet.nationsBuilt() || (int)planet.cellNation().size() != (int)planet.cells.size()) rebuildTerritory();
showRealms = !showRealms;
setStatus(showRealms ? "Realm borders on" : "Realm borders off");
}
void Viewer::placeOrToggleSettlements() { // U: seed on first press ("the dawn"), then toggle markers
if (!settled) return;
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",
std::string(TextFormat("%d villages founded", (int)planet.settlements.size())));
setStatus(TextFormat("Civilization begins (%d settlements)", (int)planet.settlements.size()));
} else {
showSettlements = !showSettlements;
setStatus(showSettlements ? "Settlements on" : "Settlements off");
}
}
void Viewer::toggleHabitabilityView() { // I: habitability heat-map view
if (!settled) return;
planet.computeHabitability();
mode = (mode == ColorMode::Habitability) ? ColorMode::Biome : ColorMode::Habitability;
recolor();
setStatus(mode == ColorMode::Habitability ? "Habitability on" : "Habitability off");
}
void Viewer::toggleEcoregionView() { // E: ecoregion atlas colour view (names ecology on first use)
if (!settled) return;
if (!planet.ecoregionsBuilt()) planet.generateEcoregions();
mode = (mode == ColorMode::Ecoregion) ? ColorMode::Biome : ColorMode::Ecoregion;
recolor();
setStatus(mode == ColorMode::Ecoregion ? "Ecoregions on" : "Ecoregions off");
}
void Viewer::generateOrRegenerateBiota() { // L: generate / regenerate the biota population
if (!settled) return;
planet.generateBiota();
if (planet.ecoregionsBuilt()) planet.generateEcoregions();
if (mode != ColorMode::FaunaDensity && mode != ColorMode::FungaDensity)
{ mode = ColorMode::FloraDensity; recolor(); }
setStatus("Biota generated (flora/fauna/funga)");
}
void Viewer::toggleHydrology() { // H: toggle Phase 3 (hydrology)
if (!settled) return;
phase3 = !phase3; phase3Prompt = false;
paused = true; // switching stage auto-pauses (SPACE to run)
if (phase3) { phase3PromptAt = elapsedMy; setStatus("Hydrology ON - paused (SPACE to run)"); }
else { phase3PromptAt = elapsedMy + planet.cfg.phase3AfterMy; rivers.clear(); bigRivers.clear(); setStatus("Hydrology OFF - paused"); }
refreshView();
}
void Viewer::enterOrLeaveLiveWorld() { // W: enter / leave Live World (slow real-time clock)
if (!settled) return;
liveWorld = !liveWorld;
if (liveWorld) {
phase3Prompt = false; paused = true; wxUndo.clear(); // enter Live World paused (SPACE to run)
if (planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); // one-time tectonic-context placement
if (!planet.geographyBuilt()) planet.generateGeography(); // name the world's geography (the atlas)
refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started - paused (SPACE to run)");
} else {
paused = true; followId = 0; wxUndo.clear(); refreshView(); // back to World Creation, paused
setStatus("Live World stopped");
}
}
void Viewer::cycleFollowStorm() { // Y: cycle the 3D camera through active storms
if (!liveWorld) return;
const auto& st = planet.storms();
if (st.empty()) { followId = 0; setStatus("No weather systems to follow"); return; }
std::vector<int> idx(st.size()); for (size_t i = 0; i < st.size(); ++i) idx[i] = (int)i;
std::sort(idx.begin(), idx.end(), [&](int a, int b){ return st[a].strength > st[b].strength; });
int cur = -1; for (size_t k = 0; k < idx.size(); ++k) if (st[idx[k]].id == followId) { cur = (int)k; break; }
int next = (cur < 0) ? 0 : cur + 1;
if (next >= (int)idx.size()) { followId = 0; setStatus("Follow cam off"); return; }
const WeatherSystem& ws = st[idx[next]];
followId = ws.id;
bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr;
setStatus(hur ? "Following tropical cyclone" : "Following weather system");
}
void Viewer::toggleNames() { // M: toggle place-name labels (the atlas)
if (!settled) return;
if (!planet.geographyBuilt()) planet.generateGeography(); // lazily name the world on first use
showNames = !showNames;
setStatus(showNames ? "Place names on" : "Place names off");
}
void Viewer::reshuffleNames() { // Shift+M: re-extract current geography + use different names
if (!settled) return;
planet.reshuffleGeography();
showNames = true;
setStatus("Place names reshuffled");
}
void Viewer::reseed() { // R: reseed
cfg.seed = (uint32_t)(GetTime() * 100000) | 1;
regen();
}
void Viewer::stepAction() { // S: one step (forming/drift tick, or Live World clock step)
if (liveWorld) liveStepForward();
else { stepOnce(); refreshView(); }
}
void Viewer::fastForward() { // F: fast-forward Phase-1 forming to settled
if (settled) return;
while (!settled) stepOnce();
dtMy = planet.cflDtMy(); planet.drifting = true; refreshView();
}
void Viewer::toggleEditMode() { // F3: toggle edit mode (manual cell/settlement edits)
if (!settled) return;
editMode = !editMode;
if (editMode) { paused = true; setStatus("Edit mode on - click a tile to edit (F3 to exit)"); }
else { exitEditMode(); setStatus("Edit mode off"); }
}
// 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());
wxUndo.push_back(WxFrame{ liveTime, planet.captureWeather() });
}
// Step the live clock forward one rate-unit. Auto-pauses (like a video frame-step); always records
// the pre-step snapshot first (rate-independent) so the backward step restores weather + storms.
void Viewer::liveStepForward() {
paused = true;
wxPushSnapshot();
liveAdvance(liveRate, liveRate);
}
// Step everything back: restore the newest snapshot at or before the current time (clock +
// weather + storms) -- so storms reverse whether they were born while stepping or during a run. If
// the history is exhausted, fall back to rewinding the deterministic sky only.
void Viewer::liveStepBack() {
paused = true;
while (!wxUndo.empty() && wxUndo.back().t > liveTime + 1e-6) wxUndo.pop_back(); // drop only true future frames
if (!wxUndo.empty()) {
WxFrame f = wxUndo.back(); wxUndo.pop_back();
liveTime = f.t;
planet.restoreWeather(f.w);
liveAdvance(0.0, 0.0); // recompute the sky/overlay at the restored time (weather held)
setStatus("Step back");
} else {
liveAdvance(-liveRate, 0.0); // no recorded past (e.g. right after a load): sky rewinds, weather holds
setStatus("Step back (sky only - no earlier weather; play/step forward first)");
}
}
// The 2D map's projection rect after zoom/pan: mapRect scaled about its centre by mapZoom and
// shifted by the screen-space pan. The scissor + frame stay the real mapRect, so it clips cleanly.
Rectangle Viewer::mapViewRect() const {
float w = (float)(mapRect.width * mapZoom), h = (float)(mapRect.height * mapZoom);
float x = mapRect.x + (mapRect.width - w) * 0.5f + (float)mapPanX;
float y = mapRect.y + (mapRect.height - h) * 0.5f + (float)mapPanY;
return Rectangle{ x, y, w, h };
}
void Viewer::run() {
while (!WindowShouldClose()) {
handleInput();
stepSim();
// A regenerate this frame may have shrunk the planet; keep indices valid.
if (hovered >= (int)planet.cells.size()) hovered = -1;
renderFrame();
}
UnloadRenderTexture(rt3d);
CloseWindow();
}