Compare commits

..

2 Commits

Author SHA1 Message Date
a9ea113f97 Document the independent realm-name/culture-gating changes in CLAUDE.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
2026-08-30 21:54:25 +02:00
af7b6146fd Give realms independent names, gate colonization on culture, label culture-less realms
1. Realm names no longer literally reuse the capital settlement's own name
   ("Kingdom of X" where X == the city). Kingdoms/empires now get an
   independently generated "place name" (namegen::makeName, keyed on the
   capital settlement's permanent id + its own language bank, deterministic
   and stable across yearly recomputes) so a realm and the city it's ruled
   from read as two distinct places, same as any real one.

2. stepColonization() now skips a realm with no assigned culture
   (nat.cultureId < 0) -- nothing to pass on to a new colony, and it stops a
   culture-less realm from propagating that gap to its colonies.

3. Realm labels (3D, 2D, and the atlas export) now always show a culture
   line -- either the real "(the Velmar)" or an explicit "(cultureless)" in
   a muted colour, instead of silently omitting the line when
   Nation.cultureId is unset. Makes a genuinely culture-less realm (can
   still arise from conquest/schism edge cases) a readable, intentional-
   looking state rather than a gap that looks like a rendering bug.

test_culture.cpp updated to rebuild the new independent name deterministically
(mirroring PlanetCulture.cpp's private name-generation formula) instead of
asserting the old capital-name-based format, plus a new assertion that a
realm's place name differs from its capital's own name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
2026-08-30 21:53:01 +02:00
5 changed files with 112 additions and 50 deletions

View File

@ -455,6 +455,30 @@ on the Live World clock). **Steps 17 of the roadmap are done (plus a derived
that just sets `mode = ColorMode::Biome` (matching the plain `5` key, no side effects, works from
the very start of World Creation). Purely additive; the dropdown entry and `5` key are unchanged.
`src/render` only (`Toolbar.cpp`), no save-format change.
- **Independent realm names, a capital-label collision fix, colony/culture gating, and an explicit
"cultureless" state** *(done)* — a cluster of follow-ups after the realm-borders overlay above.
(1) **Atlas capital labels were colliding**: a capital's own settlement-name label was
systematically lost to the (higher-priority) realm-name/culture block at the same point, since
their offsets weren't real clearance, just a small nudge — fixed with an explicit bottom-up stack
(settlement's own box → culture line → realm name, each strictly above the one below it with a
real gap, sized off a conservative worst-case settlement font) so a capital's own name always
survives the decluttering pass alongside its realm's. (2) **Realm names are now independently
generated**, not literally "Kingdom of " + the capital's own name — `computeCultures()`
(`PlanetCulture.cpp`) derives a separate "place name" via `namegen::makeName()`, keyed on the
capital *settlement's* permanent id (stable across yearly recomputes, unlike `Nation.id` which is
just that pass's insertion order) and its own language bank, deduped against other realms named
the same pass — same retry pattern already used for settlement/colony naming. A kingdom and the
city it's ruled from now read as two distinct places. (3) **`stepColonization()` now requires
`nat.cultureId >= 0`** — a culture-less realm has nothing to pass on, and this stops it from
propagating that gap to new colonies (colonies inherit their founder's culture at founding).
(4) **Realm labels always show a culture line now** (3D, 2D, atlas) — either the real
`(the Velmar)` or an explicit `(cultureless)` in a muted colour, instead of silently omitting the
line when `Nation.cultureId` is unset (which can still arise from older conquest/schism edge cases
predating fix 3) — makes it a readable, intentional-looking state rather than a gap that looks like
a bug. `test_culture.cpp` updated to rebuild the new deterministic name formula and assert it
differs from the capital's own name. Touches `src/sim` (`PlanetCulture.cpp`, `PlanetCiv.cpp`) as
well as `src/render``Nation.name`/`cultureId` are derived/not saved (per the Territory & Culture
entries above), so no save-format version bump.
## Current state

View File

@ -485,16 +485,16 @@ void Viewer::drawMapOverlays(Rectangle vr, double lonOffset, float scale, float
int w = MeasureText(nat.name.c_str(), font);
DrawText(nat.name.c_str(), (int)lp.x - w / 2 + 1, nameFillY + 1, font, Color{0, 0, 0, 205});
DrawText(nat.name.c_str(), (int)lp.x - w / 2, nameFillY, font, Color{245, 235, 210, 255});
if (nat.cultureId >= 0 && nat.cultureId < (int)planet.cultureList().size()) {
const std::string& cname = planet.cultureList()[nat.cultureId].name;
if (!cname.empty()) {
std::string ctext = "(" + cname + ")";
int cw = MeasureText(ctext.c_str(), cultureFont);
int cFillY = (int)lp.y - cultureFont - 8;
DrawText(ctext.c_str(), (int)lp.x - cw / 2 + 1, cFillY + 1, cultureFont, Color{0, 0, 0, 205});
DrawText(ctext.c_str(), (int)lp.x - cw / 2, cFillY, cultureFont, Color{215, 205, 180, 235});
}
}
// Always show the culture line, even when the realm has none yet -- "(cultureless)" makes
// that an explicit, readable state rather than an unlabeled gap that looks like a bug.
bool hasCulture = nat.cultureId >= 0 && nat.cultureId < (int)planet.cultureList().size()
&& !planet.cultureList()[nat.cultureId].name.empty();
std::string ctext = hasCulture ? "(" + planet.cultureList()[nat.cultureId].name + ")" : "(cultureless)";
Color ccol = hasCulture ? Color{215, 205, 180, 235} : Color{140, 140, 150, 200};
int cw = MeasureText(ctext.c_str(), cultureFont);
int cFillY = (int)lp.y - cultureFont - 8;
DrawText(ctext.c_str(), (int)lp.x - cw / 2 + 1, cFillY + 1, cultureFont, Color{0, 0, 0, 205});
DrawText(ctext.c_str(), (int)lp.x - cw / 2, cFillY, cultureFont, ccol);
}
}
// Place-name labels (the atlas). Minor features only when the map is zoomed in (on-screen) or
@ -640,22 +640,22 @@ void Viewer::exportAtlasImage() {
double lon, lat; dirToLonLat(planet.cells[cell].unit, lon, lat);
Vector2 lp = projLonLat(lon, lat, mapLon, er);
float cultureFont = std::max(8.0f, std::round(font * 0.72f));
// Always give the realm a culture line, even when it has none yet -- "(cultureless)"
// makes that an explicit, readable state (e.g. a very young realm) rather than an
// unlabeled gap that looks like a bug.
bool hasCulture = nat.cultureId >= 0 && nat.cultureId < (int)planet.cultureList().size()
&& !planet.cultureList()[nat.cultureId].name.empty();
std::string ctext = hasCulture ? "(" + planet.cultureList()[nat.cultureId].name + ")" : "(cultureless)";
Color ccol = hasCulture ? Color{215, 205, 180, 235} : Color{140, 140, 150, 200};
float stackTop = lp.y - settleFontMax * 0.5f - pad; // top edge of the settlement's own box
float cultureCenterY = stackTop - pad - cultureFont * 0.5f;
float realmCenterY = (hasCulture ? cultureCenterY - cultureFont * 0.5f - pad : stackTop - pad)
- pad - font * 0.5f;
float realmCenterY = cultureCenterY - cultureFont * 0.5f - pad - pad - font * 0.5f;
labels.push_back({ Vector2{lp.x, realmCenterY}, nat.name, font, Color{245, 235, 210, 255},
prio, (float)nat.totalPop, false });
// The realm's dominant culture, as a smaller "(the Velmar)" label right above the realm
// name -- a lower-priority candidate in the same greedy pass, so on a dense world it
// simply drops out on its own if it doesn't fit, without any special-case logic.
if (hasCulture) {
const std::string& cname = planet.cultureList()[nat.cultureId].name;
labels.push_back({ Vector2{lp.x, cultureCenterY}, "(" + cname + ")", (int)cultureFont,
Color{215, 205, 180, 235}, prio + 1, (float)nat.totalPop, false });
}
// A lower-priority candidate in the same greedy pass, so on a dense world it simply drops
// out on its own if it doesn't fit, without any special-case logic.
labels.push_back({ Vector2{lp.x, cultureCenterY}, ctext, (int)cultureFont, ccol,
prio + 1, (float)nat.totalPop, false });
}
}
if (!planet.settlements.empty()) {
@ -1292,16 +1292,16 @@ void Viewer::renderFrame() {
int w = MeasureText(nat.name.c_str(), font);
DrawText(nat.name.c_str(), (int)sx - w / 2 + 1, nameFillY + 1, font, Color{0, 0, 0, 205});
DrawText(nat.name.c_str(), (int)sx - w / 2, nameFillY, font, Color{245, 235, 210, 255});
if (nat.cultureId >= 0 && nat.cultureId < (int)planet.cultureList().size()) {
const std::string& cname = planet.cultureList()[nat.cultureId].name;
if (!cname.empty()) {
std::string ctext = "(" + cname + ")";
int cw = MeasureText(ctext.c_str(), cultureFont);
int cFillY = (int)sy - cultureFont - 8;
DrawText(ctext.c_str(), (int)sx - cw / 2 + 1, cFillY + 1, cultureFont, Color{0, 0, 0, 205});
DrawText(ctext.c_str(), (int)sx - cw / 2, cFillY, cultureFont, Color{215, 205, 180, 235});
}
}
// Always show the culture line, even when the realm has none yet -- "(cultureless)" makes
// that an explicit, readable state rather than an unlabeled gap that looks like a bug.
bool hasCulture = nat.cultureId >= 0 && nat.cultureId < (int)planet.cultureList().size()
&& !planet.cultureList()[nat.cultureId].name.empty();
std::string ctext = hasCulture ? "(" + planet.cultureList()[nat.cultureId].name + ")" : "(cultureless)";
Color ccol = hasCulture ? Color{215, 205, 180, 235} : Color{140, 140, 150, 200};
int cw = MeasureText(ctext.c_str(), cultureFont);
int cFillY = (int)sy - cultureFont - 8;
DrawText(ctext.c_str(), (int)sx - cw / 2 + 1, cFillY + 1, cultureFont, Color{0, 0, 0, 205});
DrawText(ctext.c_str(), (int)sx - cw / 2, cFillY, cultureFont, ccol);
}
}

View File

@ -306,6 +306,7 @@ std::vector<WarEvent> Planet::stepColonization(long year) {
if ((int)settlements.size() >= totalCap) break;
const Nation& nat = nations[ni];
if (nat.totalPop < cfg.civColonyMinPop) continue; // only sizeable realms (kingdoms+)
if (nat.cultureId < 0) continue; // a culture-less realm doesn't found colonies (nothing to pass on)
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;

View File

@ -4,6 +4,7 @@
#include <cctype>
#include <cmath>
#include <unordered_map>
#include <unordered_set>
// --- Civilization Step 4 + Step 8: cultures, beliefs, governments & cultural evolution --------------
// Settlements share a CULTURE (a people/language family with an environment-driven ethos + a religion);
@ -229,7 +230,15 @@ void Planet::computeCultures() {
}
// Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and
// tag the realm with its capital's culture.
// tag the realm with its capital's culture. The realm's "place name" is independently generated
// (NOT the capital settlement's own name) -- a kingdom and the city it's ruled from read as two
// distinct places, same as any real one. Deterministic: keyed on the capital SETTLEMENT's
// permanent id (stable even as territory/nations get rebuilt from scratch every year -- unlike
// Nation.id, which is just this pass's insertion order and would make the name flicker) and its
// own language bank, with a different hash salt than the settlement's own name so the two only
// rarely coincide; deduped against other realms named this same pass, same retry pattern as
// settlement/colony naming elsewhere in this codebase.
std::unordered_set<std::string> usedRealmPlaces;
for (Nation& nat : nations) {
nat.cultureId = (nat.capital >= 0 && nat.capital < (int)sSettleCulture.size())
? sSettleCulture[nat.capital] : -1;
@ -239,16 +248,24 @@ void Planet::computeCultures() {
case NationTier::Kingdom: nat.gov = (gh == 0) ? GovType::Kingdom : (gh == 1) ? GovType::Duchy : GovType::Theocracy; break;
case NationTier::Empire: nat.gov = (gh == 0) ? GovType::Empire : (gh == 1) ? GovType::Autocracy: GovType::Confederation; break;
}
const std::string& capName = settlements[nat.capital].name;
uint32_t capId = (nat.capital >= 0 && nat.capital < (int)settlements.size())
? settlements[nat.capital].id : 0u;
int capBank = (nat.capital >= 0 && nat.capital < (int)settlements.size())
? settlements[nat.capital].bank : 0;
uint32_t placeSeed = seed ^ cultHash(capId * 2654435761u ^ 0x5EA1C1Cu);
std::string place = namegen::makeName(placeSeed, capBank);
for (int g = 0; usedRealmPlaces.count(place) && g < 128; ++g)
place = namegen::makeName(placeSeed += 0x9E3779B9u, capBank);
usedRealmPlaces.insert(place);
switch (nat.gov) {
case GovType::Tribe: nat.name = "Chiefdom of " + capName; break;
case GovType::CityRepublic: nat.name = "Republic of " + capName; break;
case GovType::Duchy: nat.name = "Duchy of " + capName; break;
case GovType::Kingdom: nat.name = "Kingdom of " + capName; break;
case GovType::Theocracy: nat.name = capName + " Theocracy"; break;
case GovType::Confederation: nat.name = capName + " Confederation"; break;
case GovType::Empire: nat.name = capName + " Empire"; break;
case GovType::Autocracy: nat.name = capName + " Dominion"; break;
case GovType::Tribe: nat.name = "Chiefdom of " + place; break;
case GovType::CityRepublic: nat.name = "Republic of " + place; break;
case GovType::Duchy: nat.name = "Duchy of " + place; break;
case GovType::Kingdom: nat.name = "Kingdom of " + place; break;
case GovType::Theocracy: nat.name = place + " Theocracy"; break;
case GovType::Confederation: nat.name = place + " Confederation"; break;
case GovType::Empire: nat.name = place + " Empire"; break;
case GovType::Autocracy: nat.name = place + " Dominion"; break;
}
}
@ -380,6 +397,8 @@ std::vector<WarEvent> Planet::stepCulture(long year) {
std::vector<int> distant; int far = -1; double farD = 0.0;
for (size_t s = 0; s < ns; ++s) {
if (!living(s) || sSettleCulture[s] != (int)ci) continue;
int sc = settlements[s].cell;
if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue;
double d = ang(unitOf(s), centroid);
if (d > cfg.cultSchismRange) {
distant.push_back((int)s);

View File

@ -14,12 +14,14 @@
// determinism + RNG isolation; save->load->recompute parity.
#include "Planet.hpp"
#include "NameGen.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <set>
#include <map>
#include <sstream>
#include <unordered_set>
static int failures = 0;
static void check(bool cond, const char* what) {
@ -99,25 +101,40 @@ int main() {
std::printf("Culture: governments plausible per tier + reflected in realm name\n");
{
bool govOk = true, nameOk = true, cultOk = true;
// Mirrors PlanetCulture.cpp's private cultHash() exactly, to rebuild the independently
// generated realm "place name" (deliberately NOT the capital settlement's own name -- a
// kingdom and the city it's ruled from read as two distinct places).
auto cultHash = [](uint32_t a) -> uint32_t { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; };
bool govOk = true, nameOk = true, cultOk = true, distinctOk = true;
std::unordered_set<std::string> usedRealmPlaces;
const uint32_t seed = p.cfg.seed ? p.cfg.seed : 1u;
for (const Nation& nat : p.nationList()) {
bool tierOk =
(nat.tier == NationTier::CityState && (nat.gov == GovType::CityRepublic || nat.gov == GovType::Tribe || nat.gov == GovType::Theocracy)) ||
(nat.tier == NationTier::Kingdom && (nat.gov == GovType::Kingdom || nat.gov == GovType::Duchy || nat.gov == GovType::Theocracy)) ||
(nat.tier == NationTier::Empire && (nat.gov == GovType::Empire || nat.gov == GovType::Autocracy || nat.gov == GovType::Confederation));
if (!tierOk) govOk = false;
// Rebuild the expected government-aware name and compare.
// Rebuild the expected government-aware name (independent place name, not the capital's
// own) and compare.
const std::string& cap = p.settlements[nat.capital].name;
uint32_t capId = p.settlements[nat.capital].id;
int capBank = p.settlements[nat.capital].bank;
uint32_t placeSeed = seed ^ cultHash(capId * 2654435761u ^ 0x5EA1C1Cu);
std::string place = namegen::makeName(placeSeed, capBank);
for (int g = 0; usedRealmPlaces.count(place) && g < 128; ++g)
place = namegen::makeName(placeSeed += 0x9E3779B9u, capBank);
usedRealmPlaces.insert(place);
if (place == cap) distinctOk = false; // extremely unlikely, but the realm name must read as its own place
std::string exp;
switch (nat.gov) {
case GovType::Tribe: exp = "Chiefdom of " + cap; break;
case GovType::CityRepublic: exp = "Republic of " + cap; break;
case GovType::Duchy: exp = "Duchy of " + cap; break;
case GovType::Kingdom: exp = "Kingdom of " + cap; break;
case GovType::Theocracy: exp = cap + " Theocracy"; break;
case GovType::Confederation: exp = cap + " Confederation"; break;
case GovType::Empire: exp = cap + " Empire"; break;
case GovType::Autocracy: exp = cap + " Dominion"; break;
case GovType::Tribe: exp = "Chiefdom of " + place; break;
case GovType::CityRepublic: exp = "Republic of " + place; break;
case GovType::Duchy: exp = "Duchy of " + place; break;
case GovType::Kingdom: exp = "Kingdom of " + place; break;
case GovType::Theocracy: exp = place + " Theocracy"; break;
case GovType::Confederation: exp = place + " Confederation"; break;
case GovType::Empire: exp = place + " Empire"; break;
case GovType::Autocracy: exp = place + " Dominion"; break;
}
if (nat.name != exp) nameOk = false;
if (nat.cultureId < 0 || nat.cultureId >= (int)p.cultureList().size()) cultOk = false;
@ -125,6 +142,7 @@ int main() {
check(govOk, "each realm's government fits its tier");
check(nameOk, "each realm name reflects its government");
check(cultOk, "each realm is tagged with a valid culture");
check(distinctOk, "each realm's place name differs from its capital's own name");
}
std::printf("Culture: realms are mono-cultural (no realm spans two continents)\n");