Civ: cluster settlement placement (habitability-weighted, irregular)
Placement used greedy farthest-first with a hard minimum spacing, so settlements came out as a near-uniform lattice (unrealistic). Now placeSettlements() does habitability-weighted random sampling (weight = habitability^civClusterExp) with a soft Gaussian suppression (civMinSpacingRadians) around each pick, so towns cluster on good land (rivers/coasts/fertile valleys) at irregular spacing and leave empty stretches between. Nearest-neighbour distances now span ~0.04..0.39 rad (was ~uniform) and placement concentrates on the better cells. - New knob civClusterExp (3.0; higher = tighter clustering on the best land); civMinSpacingRadians repurposed as the soft suppression scale (0.10 -> 0.06). - Separate sCivRng + deterministic, so determinism / RNG isolation hold. - test_civ: replaced the hard-spacing assertion with clustering checks (nearest-neighbour spacing varies; placed cells beat the habitable mean). All 10 suites pass; GUI build clean. Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3655dc0661
commit
868cc90667
3
BUILD.md
3
BUILD.md
@ -284,7 +284,8 @@ Civilization (PlanetConfig, key U): settlements placed once on the best fertile
|
|||||||
population grows/declines on the Live World clock toward a food-driven carrying capacity. Saved v20.
|
population grows/declines on the Live World clock toward a food-driven carrying capacity. Saved v20.
|
||||||
|
|
||||||
civMaxSettlements 80 cap on settlement sites
|
civMaxSettlements 80 cap on settlement sites
|
||||||
civMinSpacingRadians 0.10 min angular gap between sites (~640 km)
|
civMinSpacingRadians 0.06 soft suppression scale around each pick (lower = tighter clusters)
|
||||||
|
civClusterExp 3.0 habitability weighting for placement (higher = clusters on the best land)
|
||||||
civMinHabitability 0.22 don't place a settlement below this habitability
|
civMinHabitability 0.22 don't place a settlement below this habitability
|
||||||
civSeedPopulation 250 initial village population
|
civSeedPopulation 250 initial village population
|
||||||
civGrowthRate 0.02 logistic growth rate per year
|
civGrowthRate 0.02 logistic growth rate per year
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@ -132,8 +132,10 @@ on the Live World clock). **Steps 1–2 of the roadmap are done (plus a derived
|
|||||||
**habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/
|
**habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/
|
||||||
coast) + food (flora/fauna density + ecoregion productivity), gated by freezing winters / high
|
coast) + food (flora/fauna density + ecoregion productivity), gated by freezing winters / high
|
||||||
terrain; colour mode `Habitability`, key `I`). On key **`U`** ("the dawn") `placeSettlements()` seeds
|
terrain; colour mode `Habitability`, key `I`). On key **`U`** ("the dawn") `placeSettlements()` seeds
|
||||||
a fixed set **once** on the best, well-spaced (`civMinSpacingRadians`) fertile cells (separate
|
a fixed set **once** by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`)
|
||||||
`sCivRng`; named from the continent's `NameGen` bank). `stepCivilization(dtHours, liveTime)` runs each
|
with a **soft Gaussian suppression** (`civMinSpacingRadians`) around each pick — so settlements
|
||||||
|
**cluster** on good land (rivers/coasts/fertile valleys) at irregular spacing rather than an even
|
||||||
|
lattice (separate `sCivRng`; named from the continent's `NameGen` bank). `stepCivilization(dtHours, liveTime)` runs each
|
||||||
live frame: population moves **logistically toward a food-driven carrying capacity**, but everything is
|
live frame: population moves **logistically toward a food-driven carrying capacity**, but everything is
|
||||||
**environment-driven and dynamic** (not the old "grow the same everywhere"): the growth **rate** scales
|
**environment-driven and dynamic** (not the old "grow the same everywhere"): the growth **rate** scales
|
||||||
with habitability (fertile cells boom, marginal crawl); the capacity `K = civMaxPopulation·habitability·
|
with habitability (fertile cells boom, marginal crawl); the capacity `K = civMaxPopulation·habitability·
|
||||||
@ -860,9 +862,10 @@ triangles (plates are fixed in phase 1).
|
|||||||
a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints
|
a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints
|
||||||
a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
|
a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
|
||||||
constants in NameGen.cpp / ViewerRender.cpp, not config.
|
constants in NameGen.cpp / ViewerRender.cpp, not config.
|
||||||
- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement — `civMaxSettlements`
|
- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement (habitability-weighted
|
||||||
(80, cap), `civMinSpacingRadians` (0.10 rad ≈ 640 km, min gap between sites), `civMinHabitability`
|
+ clustered) — `civMaxSettlements` (80, cap), `civClusterExp` (3.0, higher = settlements cluster harder
|
||||||
(0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight`
|
on the best land), `civMinSpacingRadians` (0.06 rad, soft suppression scale around each pick — lower =
|
||||||
|
tighter clusters), `civMinHabitability` (0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight`
|
||||||
(0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty`
|
(0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty`
|
||||||
(2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial
|
(2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial
|
||||||
village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at
|
village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at
|
||||||
|
|||||||
@ -361,8 +361,10 @@ load with none and regenerate on demand.
|
|||||||
derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access
|
derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access
|
||||||
(river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion
|
(river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion
|
||||||
productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn")
|
productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn")
|
||||||
seeds a **fixed** set once — greedily the highest-habitability cells with a minimum angular spacing
|
seeds a **fixed** set once by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`)
|
||||||
(the ocean-basin farthest-first idiom) — naming each from its continent's `NameGen` bank.
|
with a **soft Gaussian suppression** (`civMinSpacingRadians`) softening nearby weights after each pick — so
|
||||||
|
settlements **cluster** on good land at irregular spacing instead of an even lattice (the earlier hard
|
||||||
|
farthest-first looked like a grid). Each is named from its continent's `NameGen` bank.
|
||||||
|
|
||||||
Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state
|
Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state
|
||||||
is each settlement's **population** — which is all the step-back snapshot stores (a `vector<double>` in
|
is each settlement's **population** — which is all the step-back snapshot stores (a `vector<double>` in
|
||||||
|
|||||||
@ -71,8 +71,12 @@ void Planet::computeHabitability() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time placement: greedily seed the highest-habitability cells with a minimum angular spacing
|
// One-time placement: habitability-WEIGHTED RANDOM sampling with soft local suppression, so settlements
|
||||||
// (the ocean-basin seeding idiom). Auto-builds geography/ecoregions first (names + productivity).
|
// CLUSTER on good land (rivers/coasts/fertile valleys) and leave irregular gaps -- not the even lattice a
|
||||||
|
// hard farthest-first spacing produced. Each habitable cell's weight is habitability^civClusterExp; after a
|
||||||
|
// site is chosen, the weight of nearby cells is multiplied by a Gaussian falloff (0 at the site -> ~1 far),
|
||||||
|
// so neighbours are unlikely but a fertile region can still host several towns at irregular spacing.
|
||||||
|
// Auto-builds geography/ecoregions first (names + productivity). Separate RNG -> tectonic determinism intact.
|
||||||
void Planet::placeSettlements() {
|
void Planet::placeSettlements() {
|
||||||
const int n = (int)cells.size();
|
const int n = (int)cells.size();
|
||||||
if (!geographyBuilt()) generateGeography();
|
if (!geographyBuilt()) generateGeography();
|
||||||
@ -82,32 +86,43 @@ void Planet::placeSettlements() {
|
|||||||
sCellSettlement.assign(n, -1);
|
sCellSettlement.assign(n, -1);
|
||||||
sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
|
sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
|
||||||
auto next = [&]() { sCivRng ^= sCivRng << 13; sCivRng ^= sCivRng >> 17; sCivRng ^= sCivRng << 5; return sCivRng; };
|
auto next = [&]() { sCivRng ^= sCivRng << 13; sCivRng ^= sCivRng >> 17; sCivRng ^= sCivRng << 5; return sCivRng; };
|
||||||
|
auto rf = [&]() { return (next() & 0xFFFFFFu) / double(0x1000000); };
|
||||||
|
|
||||||
std::vector<int> cand;
|
const double minHab = cfg.civMinHabitability, cexp = std::max(0.0, cfg.civClusterExp);
|
||||||
for (int i = 0; i < n; ++i) if (sHabitability[i] >= cfg.civMinHabitability) cand.push_back(i);
|
std::vector<double> w(n, 0.0); double total = 0.0;
|
||||||
std::sort(cand.begin(), cand.end(), [&](int a, int b) { return sHabitability[a] > sHabitability[b]; });
|
for (int i = 0; i < n; ++i)
|
||||||
|
if (cells[i].elevation > cfg.seaLevel && sHabitability[i] >= minHab) {
|
||||||
|
w[i] = std::pow(std::max(1e-6, sHabitability[i]), cexp); total += w[i];
|
||||||
|
}
|
||||||
|
|
||||||
const double sepCos = std::cos(std::max(0.01, cfg.civMinSpacingRadians));
|
const double R = std::max(0.005, cfg.civMinSpacingRadians);
|
||||||
|
const double invR2 = 1.0 / (R * R), cutCos = std::cos(std::min(3.0 * R, 3.14159));
|
||||||
const int cap = std::max(0, cfg.civMaxSettlements);
|
const int cap = std::max(0, cfg.civMaxSettlements);
|
||||||
std::set<std::string> usedNames;
|
std::set<std::string> usedNames;
|
||||||
std::vector<int> chosen;
|
while ((int)settlements.size() < cap && total > 1e-9) {
|
||||||
for (int i : cand) {
|
double r = rf() * total, acc = 0.0; int pick = -1;
|
||||||
if ((int)settlements.size() >= cap) break;
|
for (int i = 0; i < n; ++i) { if (w[i] <= 0.0) continue; acc += w[i]; if (r <= acc) { pick = i; break; } }
|
||||||
bool ok = true;
|
if (pick < 0) break;
|
||||||
for (int c : chosen) if (cells[i].unit.dot(cells[c].unit) > sepCos) { ok = false; break; }
|
int regId = ((int)sCellLand.size() == n) ? sCellLand[pick] : -1;
|
||||||
if (!ok) continue;
|
|
||||||
chosen.push_back(i);
|
|
||||||
int regId = ((int)sCellLand.size() == n) ? sCellLand[i] : -1;
|
|
||||||
int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId)
|
int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId)
|
||||||
: namegen::bankForRegion(cfg.seed, 2000 + i);
|
: namegen::bankForRegion(cfg.seed, 2000 + pick);
|
||||||
uint32_t nameSeed = next() ^ (uint32_t)(i * 2654435761u);
|
uint32_t nameSeed = next() ^ (uint32_t)(pick * 2654435761u);
|
||||||
std::string nm = namegen::makeName(nameSeed, bank);
|
std::string nm = namegen::makeName(nameSeed, bank);
|
||||||
for (int g = 0; usedNames.count(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank);
|
for (int g = 0; usedNames.count(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank);
|
||||||
usedNames.insert(nm);
|
usedNames.insert(nm);
|
||||||
Settlement st;
|
Settlement st; st.cell = pick; st.bank = bank; st.regionId = regId;
|
||||||
st.cell = i; st.bank = bank; st.regionId = regId;
|
|
||||||
st.population = cfg.civSeedPopulation; st.name = nm;
|
st.population = cfg.civSeedPopulation; st.name = nm;
|
||||||
settlements.push_back(std::move(st));
|
settlements.push_back(std::move(st));
|
||||||
|
// Soft suppression: soften nearby weights (organic clustering + irregular spacing).
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
if (w[i] <= 0.0) continue;
|
||||||
|
double cosang = cells[pick].unit.dot(cells[i].unit);
|
||||||
|
if (cosang < cutCos) continue; // far -> untouched
|
||||||
|
double ang = std::acos(std::clamp(cosang, -1.0, 1.0));
|
||||||
|
double nw = w[i] * (1.0 - std::exp(-ang * ang * invR2)); // 0 at the site -> ~1 far away
|
||||||
|
total += (nw - w[i]); w[i] = nw;
|
||||||
|
}
|
||||||
|
if (w[pick] > 0.0) { total -= w[pick]; w[pick] = 0.0; }
|
||||||
}
|
}
|
||||||
for (int k = 0; k < (int)settlements.size(); ++k) {
|
for (int k = 0; k < (int)settlements.size(); ++k) {
|
||||||
settlements[k].id = (uint32_t)(k + 1);
|
settlements[k].id = (uint32_t)(k + 1);
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
|
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
|
||||||
D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
|
D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
|
||||||
D(geoMountainElev) D(geoRiverMinDischarge) D(geoOceanSepRadians) \
|
D(geoMountainElev) D(geoRiverMinDischarge) D(geoOceanSepRadians) \
|
||||||
D(civMinSpacingRadians) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \
|
D(civMinSpacingRadians) D(civClusterExp) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \
|
||||||
D(civMaxPopulation) D(civTownPop) D(civCityPop) D(civAbandonPop) \
|
D(civMaxPopulation) D(civTownPop) D(civCityPop) D(civAbandonPop) \
|
||||||
D(civHabWaterWeight) D(civHabFoodWeight) D(civHabTempOpt) D(civHabElevPenalty) \
|
D(civHabWaterWeight) D(civHabFoodWeight) D(civHabTempOpt) D(civHabElevPenalty) \
|
||||||
D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \
|
D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \
|
||||||
@ -255,6 +255,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
|
|||||||
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
|
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
|
||||||
E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians"));
|
E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians"));
|
||||||
E(rng(cfg.civMinSpacingRadians, 0.001, 3.14159, "civMinSpacingRadians"));
|
E(rng(cfg.civMinSpacingRadians, 0.001, 3.14159, "civMinSpacingRadians"));
|
||||||
|
E(rng(cfg.civClusterExp, 0.0, 12.0, "civClusterExp"));
|
||||||
E(rng(cfg.civMinHabitability, 0.0, 1.0, "civMinHabitability"));
|
E(rng(cfg.civMinHabitability, 0.0, 1.0, "civMinHabitability"));
|
||||||
E(rng(cfg.civSeedPopulation, 1.0, 1.0e9, "civSeedPopulation"));
|
E(rng(cfg.civSeedPopulation, 1.0, 1.0e9, "civSeedPopulation"));
|
||||||
E(rng(cfg.civGrowthRate, 0.0, 100.0, "civGrowthRate"));
|
E(rng(cfg.civGrowthRate, 0.0, 100.0, "civGrowthRate"));
|
||||||
|
|||||||
@ -383,7 +383,10 @@ struct PlanetConfig {
|
|||||||
// Live World clock toward a food-driven carrying capacity. Habitability blends climate comfort,
|
// Live World clock toward a food-driven carrying capacity. Habitability blends climate comfort,
|
||||||
// water access and food (flora/fauna + ecoregion productivity).
|
// water access and food (flora/fauna + ecoregion productivity).
|
||||||
int civMaxSettlements = 80; // cap on settlement sites
|
int civMaxSettlements = 80; // cap on settlement sites
|
||||||
double civMinSpacingRadians = 0.10; // min angular separation between settlement sites (~640 km)
|
double civMinSpacingRadians = 0.06; // soft suppression scale: a new settlement softens the weight of
|
||||||
|
// cells within ~this angle (organic clustering, not a hard grid)
|
||||||
|
double civClusterExp = 3.0; // habitability weighting exponent for placement (higher = settlements
|
||||||
|
// cluster harder on the best land; 1 = mild, 0 = uniform among habitable)
|
||||||
double civMinHabitability = 0.22; // don't place a settlement below this habitability
|
double civMinHabitability = 0.22; // don't place a settlement below this habitability
|
||||||
double civSeedPopulation = 250.0; // initial village population at placement
|
double civSeedPopulation = 250.0; // initial village population at placement
|
||||||
double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity)
|
double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity)
|
||||||
|
|||||||
31
test_civ.cpp
31
test_civ.cpp
@ -61,21 +61,34 @@ int main() {
|
|||||||
const auto& S = p.settlements;
|
const auto& S = p.settlements;
|
||||||
check(!S.empty(), "settlements placed");
|
check(!S.empty(), "settlements placed");
|
||||||
bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements;
|
bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements;
|
||||||
std::set<std::string> names; bool uniqueNames = true;
|
std::set<std::string> names; bool uniqueNames = true; std::set<int> distinctCells; bool distinct = true;
|
||||||
const double sepCos = std::cos(p.cfg.civMinSpacingRadians);
|
double placedHabSum = 0.0;
|
||||||
bool spaced = true;
|
|
||||||
for (size_t a = 0; a < S.size(); ++a) {
|
for (size_t a = 0; a < S.size(); ++a) {
|
||||||
if (p.cells[S[a].cell].elevation <= sea) onLand = false;
|
if (p.cells[S[a].cell].elevation <= sea) onLand = false;
|
||||||
if (p.habitability()[S[a].cell] < p.cfg.civMinHabitability - 1e-9) aboveMin = false;
|
if (p.habitability()[S[a].cell] < p.cfg.civMinHabitability - 1e-9) aboveMin = false;
|
||||||
if (!names.insert(S[a].name).second || S[a].name.empty()) uniqueNames = false;
|
if (!names.insert(S[a].name).second || S[a].name.empty()) uniqueNames = false;
|
||||||
for (size_t b = a + 1; b < S.size(); ++b)
|
if (!distinctCells.insert(S[a].cell).second) distinct = false; // one settlement per cell
|
||||||
if (p.cells[S[a].cell].unit.dot(p.cells[S[b].cell].unit) > sepCos + 1e-9) spaced = false;
|
placedHabSum += p.habitability()[S[a].cell];
|
||||||
if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency
|
if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency
|
||||||
}
|
}
|
||||||
std::printf(" %d settlements\n", (int)S.size());
|
// Nearest-neighbour spacing should VARY (clustered, not an even lattice).
|
||||||
check(onLand, "settlements sit on land + cellSettlement index is consistent");
|
double nnMin = 1e9, nnMax = 0.0;
|
||||||
|
for (size_t a = 0; a < S.size(); ++a) {
|
||||||
|
double best = 1e9;
|
||||||
|
for (size_t b = 0; b < S.size(); ++b) if (a != b)
|
||||||
|
best = std::min(best, std::acos(std::clamp(p.cells[S[a].cell].unit.dot(p.cells[S[b].cell].unit), -1.0, 1.0)));
|
||||||
|
if (best < 1e8) { nnMin = std::min(nnMin, best); nnMax = std::max(nnMax, best); }
|
||||||
|
}
|
||||||
|
double placedHabMean = S.empty() ? 0.0 : placedHabSum / S.size();
|
||||||
|
double habMean = 0.0; int habN = 0;
|
||||||
|
for (int i = 0; i < n; ++i) if (p.cells[i].elevation > sea && p.habitability()[i] >= p.cfg.civMinHabitability) { habMean += p.habitability()[i]; ++habN; }
|
||||||
|
habMean = habN ? habMean / habN : 0.0;
|
||||||
|
std::printf(" %d settlements nn-dist %.3f..%.3f rad placed-hab %.2f vs habitable-mean %.2f\n",
|
||||||
|
(int)S.size(), nnMin, nnMax, placedHabMean, habMean);
|
||||||
|
check(onLand && distinct, "settlements sit on distinct land cells + cellSettlement index consistent");
|
||||||
check(aboveMin, "settlements only on cells >= civMinHabitability");
|
check(aboveMin, "settlements only on cells >= civMinHabitability");
|
||||||
check(spaced, "settlements respect the minimum spacing");
|
check(nnMax > nnMin * 1.8, "nearest-neighbour spacing varies (clustered, not an even lattice)");
|
||||||
|
check(placedHabMean > habMean + 0.02, "placement concentrates on the better land (clustering)");
|
||||||
check(capOk, "settlement count within the cap");
|
check(capOk, "settlement count within the cap");
|
||||||
check(uniqueNames, "settlement names are unique + non-empty");
|
check(uniqueNames, "settlement names are unique + non-empty");
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user