civ/engine.js

6732 lines
272 KiB
JavaScript
Raw Normal View History

2026-05-13 20:21:58 +09:00
class Rng {
constructor(seed) {
this.seed = seed >>> 0;
}
next() {
this.seed = (1664525 * this.seed + 1013904223) >>> 0;
return this.seed / 4294967296;
}
range(min, max) {
return min + (max - min) * this.next();
}
int(max) {
return Math.floor(this.next() * max);
}
}
class World {
2026-05-14 14:38:34 +09:00
constructor(size, rng) {
2026-05-13 20:21:58 +09:00
this.size = size;
this.count = size * size;
this.rng = rng;
this.terrain = new Uint8Array(this.count);
this.resource = new Float32Array(this.count);
this.regen = new Float32Array(this.count);
this.move = new Float32Array(this.count);
this.fertility = new Float32Array(this.count);
this.mineral = new Float32Array(this.count);
this.temperature = new Float32Array(this.count);
this.humidity = new Float32Array(this.count);
this.pheromone = new Float32Array(this.count);
this.tradeRoute = new Uint8Array(this.count);
this.farmland = new Float32Array(this.count);
this.cityPull = new Float32Array(this.count);
this.city = new Int32Array(this.count);
2026-05-17 21:08:03 +09:00
this.population = new Float32Array(this.count);
this.settledPopulation = new Float32Array(this.count);
this.mobilePopulation = new Float32Array(this.count);
this.populationCapacity = new Float32Array(this.count);
this.populationPressure = new Float32Array(this.count);
2026-09-16 13:51:43 +09:00
this.farmingKnowledge = new Float32Array(this.count);
this.metallurgyKnowledge = new Float32Array(this.count);
this.nomadInfluence = new Float32Array(this.count);
this.nomadBand = new Int32Array(this.count);
this.territoryOwner = new Int32Array(this.count);
2026-09-16 20:53:17 +09:00
this.polity = this.territoryOwner;
2026-05-17 21:08:03 +09:00
this.control = new Float32Array(this.count);
this.claim = new Float32Array(this.count);
this.contested = new Uint8Array(this.count);
2026-05-13 20:21:58 +09:00
this.dominantEthnicity = new Int32Array(this.count);
this.cultureDiversity = new Float32Array(this.count);
this.city.fill(-1);
2026-09-16 13:51:43 +09:00
this.nomadBand.fill(-1);
this.territoryOwner.fill(-1);
2026-05-17 21:08:03 +09:00
this.polity.fill(-1);
2026-05-13 20:21:58 +09:00
this.dominantEthnicity.fill(-1);
2026-05-14 14:38:34 +09:00
this.generate();
2026-09-16 20:53:17 +09:00
this.buildLandNeighbors();
}
buildLandNeighbors() {
// Terrain is fixed after generation. Keep the original dy/dx traversal order.
const starts = new Int32Array(this.count + 1);
const tiles = [];
for (let i = 0; i < this.count; i++) {
starts[i] = tiles.length;
if (this.terrain[i] === Terrain.WATER) continue;
const x = i % this.size;
const y = Math.floor(i / this.size);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= this.size || ty >= this.size) continue;
const tile = this.idx(tx, ty);
if (this.terrain[tile] !== Terrain.WATER) tiles.push(tile);
}
}
}
starts[this.count] = tiles.length;
this.landNeighborStarts = starts;
this.landNeighbors = Int32Array.from(tiles);
2026-05-13 20:21:58 +09:00
}
idx(x, y) {
return y * this.size + x;
}
generate() {
const s = this.size;
const centers = Array.from({ length: 18 }, () => ({
x: this.rng.range(0, s),
y: this.rng.range(0, s),
kind: this.rng.next()
}));
const height = new Float32Array(this.count);
const moistureMap = new Float32Array(this.count);
const mineralMap = new Float32Array(this.count);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = this.idx(x, y);
const nx = x / s - 0.5;
const ny = y / s - 0.5;
const latitude = Math.abs(ny) * 0.18;
let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12;
let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude;
let minerals = this.smoothNoise(x - 290, y + 120, 20);
for (const c of centers) {
const d = Math.hypot(x - c.x, y - c.y) / s;
if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85;
if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0;
}
height[i] = elevation;
moistureMap[i] = moisture;
mineralMap[i] = minerals;
}
}
this.smoothField(height, 2);
this.smoothField(moistureMap, 1);
const seaLevel = this.percentile(height, 0.34);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = this.idx(x, y);
const latitude = Math.abs((y / (s - 1)) * 2 - 1);
const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18);
this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1);
this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS;
}
}
this.computeHumidityFromWater(moistureMap);
for (let i = 0; i < this.count; i++) {
const elevation = height[i];
const minerals = mineralMap[i];
const temp = this.temperature[i];
const humid = this.humidity[i];
let t = this.terrain[i];
if (t !== Terrain.WATER) {
2026-05-14 20:15:23 +09:00
if (elevation > seaLevel + 0.42) t = Terrain.MOUNTAIN;
2026-05-13 20:21:58 +09:00
else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT;
else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST;
else t = Terrain.PLAINS;
}
const info = terrainInfo[t];
this.terrain[i] = t;
2026-09-16 20:53:17 +09:00
// Water does not participate in fertility simulation. Keep it explicitly at zero
// so later systems never spend work interpreting a meaningless fertility value.
this.fertility[i] = t === Terrain.WATER
? 0
: clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1);
2026-05-13 20:21:58 +09:00
this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1);
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
this.move[i] = info.move;
this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35);
}
this.smoothTerrainTypes(2);
this.ensureDesertPatches();
this.enrichWaterMargins();
}
enrichWaterMargins() {
const s = this.size;
const nextFertility = new Float32Array(this.fertility);
2026-09-16 20:53:17 +09:00
const offsets = [];
2026-05-13 20:21:58 +09:00
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const d = Math.abs(dx) + Math.abs(dy);
if (!d || d > 3) continue;
2026-09-16 20:53:17 +09:00
offsets.push([dx, dy, (4 - d) / 4]);
}
}
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = y * s + x;
if (this.terrain[i] === Terrain.WATER) continue;
let waterScore = 0;
for (const [dx, dy, weight] of offsets) {
2026-05-13 20:21:58 +09:00
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
2026-09-16 20:53:17 +09:00
if (this.terrain[ty * s + tx] === Terrain.WATER) waterScore += weight;
2026-05-13 20:21:58 +09:00
}
if (waterScore <= 0) continue;
const boost = Math.min(0.32, waterScore * 0.08);
nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1);
if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS;
}
}
for (let i = 0; i < this.count; i++) {
const info = terrainInfo[this.terrain[i]];
if (this.terrain[i] === Terrain.WATER) {
2026-09-16 20:53:17 +09:00
this.fertility[i] = 0;
this.regen[i] = info.regen * 0.7;
2026-05-13 20:21:58 +09:00
this.resource[i] = 0;
} else {
2026-09-16 20:53:17 +09:00
this.fertility[i] = nextFertility[i];
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
2026-05-13 20:21:58 +09:00
this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
}
2026-09-16 20:53:17 +09:00
this.move[i] = info.move;
2026-05-13 20:21:58 +09:00
}
}
smoothNoise(x, y, scale) {
const x0 = Math.floor(x / scale);
const y0 = Math.floor(y / scale);
const fx = smoothstep((x / scale) - x0);
const fy = smoothstep((y / scale) - y0);
const a = this.gridNoise(x0, y0);
const b = this.gridNoise(x0 + 1, y0);
const c = this.gridNoise(x0, y0 + 1);
const d = this.gridNoise(x0 + 1, y0 + 1);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
gridNoise(x, y) {
const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123;
return v - Math.floor(v);
}
computeHumidityFromWater(moistureMap) {
const s = this.size;
const radius = 10;
2026-09-16 20:53:17 +09:00
const unreachable = radius + 1;
const distance = new Uint8Array(this.count);
distance.fill(unreachable);
for (let i = 0; i < this.count; i++) {
if (this.terrain[i] === Terrain.WATER) distance[i] = 0;
}
// Exact city-block distance transform. The former implementation scanned the
// full radius around every tile; two linear passes produce the same nearest-
// water distance with O(tile count) work.
2026-05-13 20:21:58 +09:00
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
2026-09-16 20:53:17 +09:00
const i = y * s + x;
if (distance[i] === 0) continue;
let d = distance[i];
if (x > 0) d = Math.min(d, distance[i - 1] + 1);
if (y > 0) d = Math.min(d, distance[i - s] + 1);
distance[i] = Math.min(unreachable, d);
2026-05-13 20:21:58 +09:00
}
}
2026-09-16 20:53:17 +09:00
for (let y = s - 1; y >= 0; y--) {
for (let x = s - 1; x >= 0; x--) {
const i = y * s + x;
if (distance[i] === 0) continue;
let d = distance[i];
if (x + 1 < s) d = Math.min(d, distance[i + 1] + 1);
if (y + 1 < s) d = Math.min(d, distance[i + s] + 1);
distance[i] = Math.min(unreachable, d);
2026-05-13 20:21:58 +09:00
}
}
2026-09-16 20:53:17 +09:00
for (let i = 0; i < this.count; i++) {
if (this.terrain[i] === Terrain.WATER) {
this.humidity[i] = 1;
continue;
}
const d = distance[i];
const waterInfluence = d <= radius ? 1 - d / (radius + 1) : 0;
this.humidity[i] = clamp(waterInfluence * 0.78 + moistureMap[i] * 0.22, 0, 1);
2026-05-13 20:21:58 +09:00
}
this.smoothField(this.humidity, 1);
}
smoothTerrainTypes(passes) {
const s = this.size;
let source = new Uint8Array(this.terrain);
let target = new Uint8Array(this.terrain.length);
for (let pass = 0; pass < passes; pass++) {
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const counts = new Uint8Array(terrainInfo.length);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
counts[source[this.idx(tx, ty)]]++;
}
}
let bestTerrain = source[this.idx(x, y)];
let bestCount = counts[bestTerrain];
for (let t = 0; t < counts.length; t++) {
if (counts[t] > bestCount) {
bestTerrain = t;
bestCount = counts[t];
}
}
target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)];
}
}
const swap = source;
source = target;
target = swap;
}
this.terrain.set(source);
}
ensureDesertPatches() {
const candidates = [];
let desertCount = 0;
for (let i = 0; i < this.count; i++) {
if (this.terrain[i] === Terrain.DESERT) desertCount++;
if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) {
candidates.push(i);
}
}
const target = Math.max(12, Math.floor(this.count * 0.012));
if (desertCount >= target || !candidates.length) return;
candidates.sort((a, b) => {
const dryA = this.temperature[a] * (1 - this.humidity[a]);
const dryB = this.temperature[b] * (1 - this.humidity[b]);
return dryB - dryA;
});
const needed = Math.min(target - desertCount, candidates.length);
for (let n = 0; n < needed; n++) {
const center = candidates[n];
const cx = center % this.size;
const cy = Math.floor(center / this.size);
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (Math.abs(dx) + Math.abs(dy) > 2) continue;
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;
const i = this.idx(x, y);
if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) {
this.terrain[i] = Terrain.DESERT;
}
}
}
}
}
smoothField(field, passes) {
const s = this.size;
let source = field;
let target = new Float32Array(field.length);
for (let pass = 0; pass < passes; pass++) {
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
let sum = 0;
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
sum += source[this.idx(tx, ty)];
count++;
}
}
target[this.idx(x, y)] = sum / count;
}
}
const nextSource = target;
target = source === field ? new Float32Array(field.length) : field;
source = nextSource;
}
if (source !== field) field.set(source);
}
percentile(field, ratio) {
const values = Array.from(field).sort((a, b) => a - b);
return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))];
}
}
class Simulation {
2026-09-16 20:53:17 +09:00
constructor(size, initialPopulation = SimConfig.world?.initialPopulation ?? 50000) {
2026-05-13 20:21:58 +09:00
this.rng = new Rng(Date.now());
2026-05-14 14:38:34 +09:00
this.world = new World(size, this.rng);
2026-05-13 20:21:58 +09:00
this.ethnicities = new Map();
this.cities = [];
this.cityById = new Map();
this.cityBuckets = new Map();
2026-09-17 22:32:25 +09:00
this.polityCitiesCache = new Map();
2026-05-13 20:21:58 +09:00
this.polities = [];
this.polityById = new Map();
this.wars = [];
2026-09-16 13:51:43 +09:00
this.nomadBands = [];
2026-05-13 20:21:58 +09:00
this.disasters = [];
this.disasterHistory = [];
2026-05-14 14:38:34 +09:00
this.graphEvents = [];
2026-09-16 20:53:17 +09:00
this.worldHistory = [];
2026-05-13 20:21:58 +09:00
this.polityHistory = new Map();
this.deadPolityHistories = [];
this.tradeLinks = [];
this.activeTradeRouteTiles = new Set();
2026-09-16 20:53:17 +09:00
this.activePheromoneTiles = new Set();
this.nextPheromoneTiles = new Set();
this.pheromoneTouchedScratch = [];
this.manualTradeRoutes = [];
this.manualRouteProtection = new Map();
2026-05-13 20:21:58 +09:00
this.dominantEthnicityCache = new Map();
this.nextEthnicity = 1;
this.nextCity = 1;
this.nextPolity = 1;
this.nextWar = 1;
2026-09-16 13:51:43 +09:00
this.nextNomadBand = 1;
2026-05-13 20:21:58 +09:00
this.nextDisaster = 1;
2026-05-17 21:08:03 +09:00
this.nextCampaign = 1;
2026-05-13 20:21:58 +09:00
this.year = 0;
this.deaths = 0;
2026-05-17 21:08:03 +09:00
this.campaigns = [];
this.campaignHistory = [];
this.territorialClaims = new Map();
2026-09-16 13:51:43 +09:00
this.territoryDebug = {
disconnectedTerritoryRemoved: 0,
independentCitiesAbsorbedByTerritory: 0,
cityTerritoryMismatchesFixed: 0,
invalidCampaignTargetsRejected: 0
};
2026-05-17 21:08:03 +09:00
this.populationFieldInitialized = false;
this.tileEthnicMix = new Map();
this.tileCultures = this.tileEthnicMix;
2026-09-16 13:51:43 +09:00
this.activePopulationTiles = new Set();
this.radiusOffsetCache = new Map();
this.scratchFloatA = new Float32Array(this.world.count);
this.scratchFloatB = new Float32Array(this.world.count);
this.scratchFloatC = new Float32Array(this.world.count);
2026-09-16 20:53:17 +09:00
this.populationCapacityStatic = new Float32Array(this.world.count);
this.populationCapacityDynamicScale = new Float32Array(this.world.count);
this.initializePopulationCapacityCache();
this.territoryBestSupport = new Float32Array(this.world.count);
this.territoryIncumbentSupport = new Float32Array(this.world.count);
2026-09-16 13:51:43 +09:00
this.scratchIntA = new Int32Array(this.world.count);
this.scratchIntB = new Int32Array(this.world.count);
this.scratchUintA = new Uint8Array(this.world.count);
this.visitStamp = new Int32Array(this.world.count);
this.nextVisitStamp = 1;
this.cityScoreScratch = [];
this.pairCandidateScratch = [];
this.routeCandidateScratch = [];
this.supportedRouteScratch = new Set();
this.populationMoveScratch = [];
this.activePopulationScratch = [];
2026-09-17 22:32:25 +09:00
this.territoryExitCandidates = [];
this.suppressTerritoryExitShock = false;
2026-09-16 13:51:43 +09:00
this.technologySourceScratch = [];
this.routeCostScratch = new Float32Array(this.world.count);
this.routePrevScratch = new Int32Array(this.world.count);
this.routeHeapScratch = [];
this.seedInitialPopulationFieldDirectly(initialPopulation);
this.recomputeTerritories({ allowOwnershipChanges: true, reason: "initial" });
2026-05-13 20:21:58 +09:00
this.updateEthnicStats();
2026-09-16 20:53:17 +09:00
this.sampleWorldHistory();
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
markPopulationTile(tile) {
if (tile >= 0 && tile < this.world.count) this.activePopulationTiles.add(tile);
}
cleanupPopulationTile(tile) {
if (tile < 0 || tile >= this.world.count) return;
if ((this.world.population[tile] || 0) > 0.01 || this.tileEthnicMix.has(tile)) return;
this.activePopulationTiles.delete(tile);
}
rebuildActivePopulationTiles() {
const w = this.world;
for (const tile of this.activePopulationTiles) {
if ((w.population[tile] || 0) <= 0.01 && !this.tileEthnicMix.has(tile)) this.activePopulationTiles.delete(tile);
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
for (const tile of this.tileEthnicMix.keys()) {
if ((w.population[tile] || 0) > 0.01 || this.tileEthnicMix.get(tile)?.size) this.activePopulationTiles.add(tile);
2026-05-13 20:21:58 +09:00
}
}
2026-09-16 13:51:43 +09:00
activePopulationSnapshot() {
this.activePopulationScratch.length = 0;
for (const tile of this.activePopulationTiles) this.activePopulationScratch.push(tile);
return this.activePopulationScratch;
}
getRadiusOffsets(radius, includeCenter = true) {
const key = `${radius}:${includeCenter ? 1 : 0}`;
let offsets = this.radiusOffsetCache.get(key);
if (offsets) return offsets;
offsets = [];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const distance = Math.abs(dx) + Math.abs(dy);
if (distance > radius || (!includeCenter && distance === 0)) continue;
offsets.push({ dx, dy, distance });
}
}
this.radiusOffsetCache.set(key, offsets);
return offsets;
}
forCardinalNeighbors(tile, visitor) {
const w = this.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
if (x > 0 && visitor(tile - 1, x - 1, y) === false) return false;
if (x < w.size - 1 && visitor(tile + 1, x + 1, y) === false) return false;
if (y > 0 && visitor(tile - w.size, x, y - 1) === false) return false;
if (y < w.size - 1 && visitor(tile + w.size, x, y + 1) === false) return false;
return true;
}
nextVisitMarker() {
if (this.nextVisitStamp >= 2147483640) {
this.visitStamp.fill(0);
this.nextVisitStamp = 1;
}
return this.nextVisitStamp++;
}
topHeapPush(heap, item, limit, scoreKey) {
if (limit <= 0) return;
if (heap.length < limit) {
heap.push(item);
this.heapSiftUp(heap, heap.length - 1, scoreKey);
return;
}
if (item[scoreKey] <= heap[0][scoreKey]) return;
heap[0] = item;
this.heapSiftDown(heap, 0, scoreKey);
}
heapSiftUp(heap, index, scoreKey) {
while (index > 0) {
const parent = (index - 1) >> 1;
if (heap[parent][scoreKey] <= heap[index][scoreKey]) break;
const swap = heap[parent];
heap[parent] = heap[index];
heap[index] = swap;
index = parent;
}
}
heapSiftDown(heap, index, scoreKey) {
for (;;) {
let smallest = index;
const left = index * 2 + 1;
const right = left + 1;
if (left < heap.length && heap[left][scoreKey] < heap[smallest][scoreKey]) smallest = left;
if (right < heap.length && heap[right][scoreKey] < heap[smallest][scoreKey]) smallest = right;
if (smallest === index) return;
const swap = heap[index];
heap[index] = heap[smallest];
heap[smallest] = swap;
index = smallest;
}
}
heapToDescending(heap, scoreKey) {
return heap.sort((a, b) => b[scoreKey] - a[scoreKey]);
}
2026-05-13 20:21:58 +09:00
createEthnicity(parent, climate = null) {
const id = this.nextEthnicity++;
this.ethnicities.set(id, {
id,
parent,
born: this.year,
population: 0,
diversity: 0,
climateTemp: climate?.temperature ?? 0.55,
climateHumidity: climate?.humidity ?? 0.45,
color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56),
centroidX: 0,
2026-09-16 20:53:17 +09:00
centroidY: 0,
nextDivergenceCheck: this.year + years(this.rng.range(18, 72) / clamp(SimConfig.culture?.divergenceRateMultiplier ?? 0.25, 0.05, 1))
2026-05-13 20:21:58 +09:00
});
return id;
}
randomTraits() {
return {
mobility: this.rng.range(0.18, 0.85),
resourceAttraction: this.rng.range(0.55, 1),
assimilation: this.rng.range(0.03, 0.34),
ethnocentrism: this.rng.range(0.12, 0.78),
reproductionThreshold: this.rng.range(19, 34),
sedentary: this.rng.range(0.08, 0.82)
};
}
desertFounderTraits() {
const traits = this.randomTraits();
traits.mobility = this.rng.range(0.48, 0.9);
traits.resourceAttraction = this.rng.range(0.82, 1.12);
traits.reproductionThreshold = this.rng.range(24, 38);
traits.sedentary = this.rng.range(0.18, 0.58);
return traits;
}
findHabitableTile(preferredTerrain = null) {
if (preferredTerrain !== null) {
const exact = [];
let best = null;
let bestScore = -Infinity;
for (let i = 0; i < this.world.count; i++) {
const x = i % this.world.size;
const y = Math.floor(i / this.world.size);
if (this.world.terrain[i] === preferredTerrain) {
exact.push({ x, y });
continue;
}
if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) {
const score = this.world.temperature[i] * (1 - this.world.humidity[i]);
if (score > bestScore) {
bestScore = score;
best = { x, y };
}
}
}
if (exact.length) return exact[this.rng.int(exact.length)];
if (best) return best;
}
for (let tries = 0; tries < 5000; tries++) {
const x = this.rng.int(this.world.size);
const y = this.rng.int(this.world.size);
const i = this.world.idx(x, y);
if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) {
return { x, y };
}
}
return { x: this.world.size >> 1, y: this.world.size >> 1 };
}
2026-05-14 14:38:34 +09:00
frontierSpawnTile(side, edgeBand = 4) {
const w = this.world;
const band = Math.max(1, Math.floor(edgeBand));
let best = null;
let bestScore = -Infinity;
for (let tries = 0; tries < 160; tries++) {
let x = this.rng.int(w.size);
let y = this.rng.int(w.size);
if (side === 0) y = this.rng.int(band);
else if (side === 1) y = w.size - 1 - this.rng.int(band);
else if (side === 2) x = this.rng.int(band);
else x = w.size - 1 - this.rng.int(band);
const i = w.idx(x, y);
if (w.terrain[i] === Terrain.WATER) continue;
const score = w.resource[i] * 0.04 + w.fertility[i] * 0.85 + w.mineral[i] * 0.25 - w.move[i] * 0.12;
if (score > bestScore) {
best = { x, y };
bestScore = score;
}
}
return best || this.findHabitableTile();
}
maybeSpawnFrontierWave() {
const cfg = SimConfig.frontierWave;
if (!cfg) return;
if (this.year < years(cfg.enabledAfterYears ?? 300)) return;
const interval = years(cfg.intervalYears ?? 80);
if (!interval || this.year % interval !== 0) return;
if (this.rng.next() > (cfg.chance ?? 0.45)) return;
2026-09-16 13:51:43 +09:00
if (SimConfig.nomad?.enabled && this.nomadBands.length < (SimConfig.nomad.maxBands ?? 24) && this.rng.next() < 0.55) {
const tile = this.findNomadSpawnTile();
if (tile != null && this.spawnNomadBand(tile)) return;
}
this.spawnFrontierPopulationWave();
}
spawnFrontierPopulationWave() {
const cfg = SimConfig.frontierWave || {};
2026-05-14 14:38:34 +09:00
const side = this.rng.int(4);
const origin = this.frontierSpawnTile(side, cfg.edgeBand ?? 4);
const originTile = this.world.idx(origin.x, origin.y);
const ethnicity = this.createEthnicity(0, {
temperature: this.world.temperature[originTile],
humidity: this.world.humidity[originTile]
});
2026-09-16 13:51:43 +09:00
const traits = this.randomTraits();
traits.mobility = this.rng.range(0.65, 1.0);
traits.sedentary = this.rng.range(0.02, 0.28);
traits.ethnocentrism = this.rng.range(0.42, 0.95);
traits.assimilation = this.rng.range(0.02, 0.22);
traits.resourceAttraction = this.rng.range(0.78, 1.15);
const lineage = this.ethnicities.get(ethnicity);
if (lineage) lineage.averageTraits = traits;
const total = this.rng.range(cfg.minPopulation ?? 60, cfg.maxPopulation ?? 140);
let seeded = 0;
for (let n = 0; n < 7; n++) {
const spawn = this.frontierSpawnTile(side, (cfg.edgeBand ?? 4) + 3);
2026-05-14 14:38:34 +09:00
const tile = this.world.idx(spawn.x, spawn.y);
if (this.world.terrain[tile] === Terrain.WATER) continue;
2026-09-16 13:51:43 +09:00
if (this.world.control[tile] > 0.65 && this.world.polity[tile] >= 0) continue;
const amount = total * this.rng.range(0.08, 0.22);
this.seedEthnicPopulationPatch(tile, ethnicity, traits, amount, 2);
seeded += amount;
}
if (seeded <= 0) {
this.ethnicities.delete(ethnicity);
return;
2026-05-14 14:38:34 +09:00
}
2026-09-16 13:51:43 +09:00
this.syncTilePopulationCulture();
this.addGraphEvent("newEthnicity", this.year, {
2026-05-14 14:38:34 +09:00
ethnicity,
2026-09-16 13:51:43 +09:00
population: Math.round(seeded)
});
}
maybeSpawnNomadBand() {
const cfg = SimConfig.nomad || {};
if (!cfg.enabled) return;
if (this.year < years(cfg.enabledAfterYears ?? 120)) return;
const interval = years(cfg.spawnCheckIntervalYears ?? 25);
if (!interval || this.year % interval !== 0) return;
if (this.nomadBands.length >= (cfg.maxBands ?? 24)) return;
if (this.rng.next() > (cfg.baseSpawnChance ?? 0.28)) return;
const tile = this.findNomadSpawnTile();
if (tile == null) return;
this.spawnNomadBand(tile);
}
findNomadSpawnTile() {
const w = this.world;
let best = null;
let bestScore = -Infinity;
for (let tries = 0; tries < 240; tries++) {
const edgeBias = this.rng.next() < 0.55;
let x = this.rng.int(w.size);
let y = this.rng.int(w.size);
if (edgeBias) {
const side = this.rng.int(4);
const band = Math.max(3, Math.floor(w.size * 0.12));
if (side === 0) y = this.rng.int(band);
else if (side === 1) y = w.size - 1 - this.rng.int(band);
else if (side === 2) x = this.rng.int(band);
else x = w.size - 1 - this.rng.int(band);
}
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER || w.city[tile] >= 0) continue;
if (w.control[tile] > 0.55 && w.polity[tile] >= 0) continue;
if (this.getCitiesNear(x, y, 8).length) continue;
if ((w.cityPull[tile] || 0) > 0.35) continue;
if ((w.settledPopulation[tile] || 0) > 8 || ((w.settledPopulation[tile] || 0) > (w.mobilePopulation[tile] || 0) * 1.8 && (w.settledPopulation[tile] || 0) > 4)) continue;
if ((w.control[tile] || 0) > 0.45 && (w.populationPressure[tile] || 0) > 0.4) continue;
const dryland = w.terrain[tile] === Terrain.DESERT ? 0.9 : 0;
const plain = w.terrain[tile] === Terrain.PLAINS ? 0.8 : w.terrain[tile] === Terrain.FOREST ? 0.15 : 0;
const frontier = w.polity[tile] < 0 ? 0.55 : (w.control[tile] < 0.35 ? 0.25 : -0.35);
const mobile = clamp((w.mobilePopulation[tile] || 0) / 20, 0, 1.2);
const route = w.tradeRoute[tile] ? 0.25 : clamp(w.pheromone[tile] / 18, 0, 0.25);
const pressure = clamp(w.populationPressure[tile] || 0, 0, 2);
const score = dryland + plain + frontier + mobile + route + w.fertility[tile] * 0.55 + clamp(w.resource[tile] / 35, 0, 0.8) - pressure * 0.8 - w.move[tile] * 0.35 + this.rng.range(0, 0.6);
if (score > bestScore) {
best = tile;
bestScore = score;
}
}
return best;
}
spawnNomadBand(originTile, options = {}) {
const cfg = SimConfig.nomad || {};
const w = this.world;
if (originTile == null || w.terrain[originTile] === Terrain.WATER) return null;
let ethnicityId = options.ethnicityId;
if (!ethnicityId) {
ethnicityId = this.createEthnicity(0, {
temperature: w.temperature[originTile],
humidity: w.humidity[originTile]
});
const ethnicity = this.ethnicities.get(ethnicityId);
if (ethnicity) {
ethnicity.averageTraits = {
...this.randomTraits(),
mobility: this.rng.range(0.72, 1.0),
sedentary: this.rng.range(0.02, 0.24),
ethnocentrism: this.rng.range(0.35, 0.9),
assimilation: this.rng.range(0.02, 0.18)
};
}
}
const population = options.population ?? Math.round(this.rng.range(cfg.minPopulation ?? 80, cfg.maxPopulation ?? 420));
const band = {
id: this.nextNomadBand++,
2026-09-16 20:53:17 +09:00
name: `遊牧集団${this.nextNomadBand - 1}`,
2026-09-16 13:51:43 +09:00
ethnicityId,
x: originTile % w.size,
y: Math.floor(originTile / w.size),
radius: Math.round(this.rng.range(cfg.minRadius ?? 5, cfg.maxRadius ?? 14)),
population,
herds: options.herds ?? population * this.rng.range(0.8, 1.8),
resources: options.resources ?? population * this.rng.range(0.10, 0.28),
cohesion: this.rng.range(0.42, 0.86),
prestige: this.rng.range(0.15, 0.75),
charisma: this.rng.range(0.55, 1.55),
aggression: this.rng.range(0.12, 0.82),
tradeAffinity: this.rng.range(0.18, 0.86),
raidAffinity: this.rng.range(0.12, 0.88),
migrationPressure: this.rng.range(0.18, 0.72),
sedentarization: this.rng.range(0.02, 0.22),
targetTile: originTile,
targetCityId: null,
mode: "roaming",
born: this.year,
lastMoveYear: -Infinity,
lastRaidYear: -Infinity,
lastTradeYear: -Infinity,
lastSplitYear: -Infinity,
trailTiles: [originTile],
influenceTiles: [],
memory: {}
};
this.nomadBands.push(band);
this.seedEthnicPopulationPatch(originTile, ethnicityId, this.ethnicities.get(ethnicityId)?.averageTraits || this.randomTraits(), population * 0.22, 2);
this.addGraphEvent("nomadBand", this.year, { bandId: band.id, ethnicity: ethnicityId, population }, 1);
this.updateNomadInfluence();
return band;
}
clearNomadInfluence() {
const w = this.world;
if (!w.nomadInfluence || !w.nomadBand) return;
w.nomadInfluence.fill(0);
w.nomadBand.fill(-1);
}
updateNomadInfluence() {
const w = this.world;
const cfg = SimConfig.nomad || {};
this.clearNomadInfluence();
for (const band of this.nomadBands) {
band.influenceTiles = [];
const cx = Math.round(band.x);
const cy = Math.round(band.y);
const popScale = clamp(Math.log1p(band.population) / Math.log(1200), 0.2, 1.3);
const strength = popScale * clamp(band.cohesion, 0.15, 1) * (0.65 + band.prestige * 0.22);
for (const offset of this.getRadiusOffsets(band.radius)) {
const dx = offset.dx;
const dy = offset.dy;
const distance = offset.distance;
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const local = strength * (1 - distance / (band.radius + 1));
if (local > w.nomadInfluence[tile]) {
w.nomadInfluence[tile] = local;
w.nomadBand[tile] = band.id;
}
band.influenceTiles.push(tile);
if (local > 0.18 && this.rng.next() < (cfg.influenceCulturalTrace ?? 0.006)) {
const amount = Math.min(0.06, band.population * (cfg.influenceMobilePopulationTrace ?? 0.004) * local);
this.addNomadTrace(tile, band.ethnicityId, amount);
}
}
}
}
addNomadTrace(tile, ethnicityId, amount) {
if (amount <= 0 || ethnicityId == null) return;
const w = this.world;
w.population[tile] += amount;
w.mobilePopulation[tile] += amount;
this.markPopulationTile(tile);
let mix = this.tileEthnicMix.get(tile);
if (!mix) {
mix = new Map();
this.tileEthnicMix.set(tile, mix);
}
mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount);
this.updateCultureTile(tile);
}
updateNomadBands() {
const cfg = SimConfig.nomad || {};
if (!cfg.enabled) return;
this.maybeSpawnNomadBand();
const interval = years(cfg.movementIntervalYears ?? 2);
for (const band of [...this.nomadBands]) {
if (!this.nomadBands.includes(band)) continue;
this.sanitizeNomadBand(band);
if (!this.nomadBands.includes(band)) continue;
if (this.year - (band.lastMoveYear ?? -Infinity) >= interval) {
this.chooseNomadTarget(band);
this.moveNomadBandTowardTarget(band);
band.lastMoveYear = this.year;
}
this.grazeNomadBand(band);
this.sanitizeNomadBand(band);
if (!this.nomadBands.includes(band)) continue;
this.interactNomadsWithCities(band);
const urbanPressure = this.nomadUrbanPressure(band);
if (urbanPressure > (cfg.urbanAbsorptionControl ?? 0.55)) this.maybeAbsorbNomadIntoUrbanRegion(band, urbanPressure);
if (!this.nomadBands.includes(band) || band.population < 25) continue;
this.maybeNomadRaidOrInvasion(band);
this.maybeFormNomadConfederation(band);
this.maybeMergeWeakNomadBand(band);
if (!this.nomadBands.includes(band)) continue;
this.splitNomadBand(band);
this.sanitizeNomadBand(band);
}
this.nomadBands = this.nomadBands.filter(band => band.population >= 25 && band.cohesion > (cfg.collapseCohesion ?? 0.12));
this.updateNomadInfluence();
}
chooseNomadTarget(band) {
const w = this.world;
const cfg = SimConfig.nomad || {};
let bestTile = w.idx(Math.round(band.x), Math.round(band.y));
let bestScore = -Infinity;
const range = cfg.maxMoveDistance ?? 9;
for (let tries = 0; tries < 80; tries++) {
const x = clamp(Math.round(band.x) + this.rng.int(range * 2 + 1) - range, 0, w.size - 1);
const y = clamp(Math.round(band.y) + this.rng.int(range * 2 + 1) - range, 0, w.size - 1);
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
const urbanPressure = this.nomadUrbanPressureAtTile(tile, band);
const terrainSuitability = w.terrain[tile] === Terrain.PLAINS ? 1.1 : w.terrain[tile] === Terrain.DESERT ? 0.85 : w.terrain[tile] === Terrain.FOREST ? 0.25 : -0.8;
const statePenalty = clamp(w.control[tile] || 0, 0, 1) * (band.aggression > 0.65 ? 0.35 : 1.25);
const cityPenalty = city ? (band.mode === "raiding" || band.mode === "trading" ? -0.25 : 1.2) : 0;
const urbanPenalty = urbanPressure * (band.mode === "raiding" || band.mode === "invading" ? 0.75 : 2.4);
const sameTrace = w.dominantEthnicity[tile] === band.ethnicityId ? 0.55 : 0;
const raidAttraction = band.aggression * band.raidAffinity > 0.42 && city ? clamp((city.storedResources || 0) / 120, 0, 1.4) : 0;
const score =
terrainSuitability +
w.fertility[tile] * 0.7 +
clamp(w.resource[tile] / 35, 0, 1.2) +
(w.tradeRoute[tile] ? 0.45 : 0) +
sameTrace +
raidAttraction -
statePenalty -
cityPenalty -
urbanPenalty -
(w.populationPressure[tile] || 0) * 0.55 -
(w.terrain[tile] === Terrain.MOUNTAIN ? 1.2 : 0) +
this.rng.range(0, 0.5);
if (score > bestScore) {
bestScore = score;
bestTile = tile;
}
}
band.targetTile = bestTile;
band.mode = bestScore > 1.2 ? "grazing" : "roaming";
}
moveNomadBandTowardTarget(band) {
const w = this.world;
const target = band.targetTile;
if (target == null) return;
const tx = target % w.size;
const ty = Math.floor(target / w.size);
let x = Math.round(band.x);
let y = Math.round(band.y);
const steps = Math.max(1, Math.min(3, Math.abs(tx - x) + Math.abs(ty - y)));
for (let step = 0; step < steps; step++) {
let best = w.idx(x, y);
let bestScore = Infinity;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [0, 0]]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
const tile = w.idx(nx, ny);
if (w.terrain[tile] === Terrain.WATER) continue;
const urbanPenalty = this.nomadUrbanPressureAtTile(tile, band) * (band.mode === "raiding" || band.mode === "invading" ? 0.5 : 1.35);
const score = Math.abs(nx - tx) + Math.abs(ny - ty) + w.move[tile] * 0.35 + clamp(w.control[tile], 0, 1) * 0.55 + urbanPenalty;
if (score < bestScore) {
bestScore = score;
best = tile;
}
}
x = best % w.size;
y = Math.floor(best / w.size);
}
band.x = x;
band.y = y;
const tile = w.idx(x, y);
band.trailTiles.push(tile);
while (band.trailTiles.length > 32) band.trailTiles.shift();
}
grazeNomadBand(band) {
const w = this.world;
const cfg = SimConfig.nomad || {};
const tile = w.idx(Math.round(band.x), Math.round(band.y));
if (w.terrain[tile] === Terrain.WATER) return;
const terrainMultiplier =
w.terrain[tile] === Terrain.PLAINS ? 1.15 :
w.terrain[tile] === Terrain.DESERT ? 0.75 :
w.terrain[tile] === Terrain.FOREST ? 0.45 :
w.terrain[tile] === Terrain.MOUNTAIN ? 0.25 : 0;
const urbanPressure = this.nomadUrbanPressure(band);
const pasture = band.population * terrainMultiplier * (0.8 + w.fertility[tile] * 1.4 + clamp(w.resource[tile] / 35, 0, 1) * 0.8) * clamp(1 - urbanPressure * 0.22, 0.35, 1);
const herdSoftCap = band.population * (cfg.herdSoftCapPerPop ?? 2.4);
const herdHardCap = band.population * (cfg.herdHardCapPerPop ?? 4.0);
const resourceSoftCap = band.population * (cfg.resourceSoftCapPerPop ?? 1.2);
const resourceHardCap = band.population * (cfg.resourceHardCapPerPop ?? 2.5);
const herdPressure = band.herds / Math.max(1, pasture);
const herdGrowthRate = (cfg.grazingGain ?? 0.035) * Math.max(0, 1 - Math.pow(herdPressure, cfg.grazingDiminishingPower ?? 1.35));
band.herds += band.herds * herdGrowthRate;
band.herds -= band.population * 0.008;
if (band.herds > herdSoftCap) band.herds -= (band.herds - herdSoftCap) * 0.06;
band.herds = clamp(band.herds, 0, herdHardCap);
const produced = Math.min(band.herds * 0.0035, band.population * 0.035) * clamp(1 - urbanPressure * 0.18, 0.4, 1);
const upkeep = band.population * (cfg.resourceConsumption ?? 0.012);
band.resources += produced - upkeep;
if (band.resources > resourceSoftCap) band.resources -= (band.resources - resourceSoftCap) * 0.08;
band.resources = clamp(band.resources, 0, resourceHardCap);
w.resource[tile] = Math.max(0, w.resource[tile] - band.population * (cfg.overgrazingDamage ?? 0.018) * 0.02);
if (band.resources < band.population * 0.03) band.cohesion = clamp(band.cohesion - 0.012, 0, 1);
else band.cohesion = clamp(band.cohesion + 0.003, 0, 1);
}
sanitizeNomadBand(band) {
const cfg = SimConfig.nomad || {};
if (!Number.isFinite(band.population)) band.population = cfg.minPopulation ?? 80;
if (!Number.isFinite(band.herds)) band.herds = Math.max(0, band.population * 1.1);
if (!Number.isFinite(band.resources)) band.resources = Math.max(0, band.population * 0.18);
band.population = Math.max(0, band.population);
band.herds = clamp(Math.max(0, band.herds), 0, Math.max(1, band.population) * (cfg.herdHardCapPerPop ?? 4.0));
band.resources = clamp(Math.max(0, band.resources), 0, Math.max(1, band.population) * (cfg.resourceHardCapPerPop ?? 2.5));
if (band.population < 25) this.nomadBands = this.nomadBands.filter(candidate => candidate !== band);
return band;
}
nomadUrbanPressure(band) {
if (!band) return 0;
const w = this.world;
const tile = w.idx(clamp(Math.round(band.x), 0, w.size - 1), clamp(Math.round(band.y), 0, w.size - 1));
return this.nomadUrbanPressureAtTile(tile, band);
}
nomadUrbanPressureAtTile(tile, band = null) {
const w = this.world;
if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
const cities = this.getCitiesNear(x, y, 12);
let cityPopulation = 0;
let nearest = Infinity;
for (const city of cities) {
cityPopulation += city.population || 0;
nearest = Math.min(nearest, Math.abs(city.x - x) + Math.abs(city.y - y));
}
const localPopulation = w.population[tile] || 0;
const settled = w.settledPopulation[tile] || 0;
const mobile = w.mobilePopulation[tile] || 0;
const settledRatio = localPopulation > 0 ? settled / Math.max(1, localPopulation) : 0;
const urbanCore = nearest <= 4 ? 0.35 : nearest <= 8 ? 0.18 : 0;
const score =
clamp(cityPopulation / 650, 0, 1.8) * 0.42 +
clamp(cities.length / 4, 0, 1.5) * 0.34 +
clamp(w.cityPull[tile] || 0, 0, 1) * 0.58 +
clamp(w.control[tile] || 0, 0, 1) * 0.36 +
clamp(w.populationPressure[tile] || 0, 0, 2) * 0.22 +
clamp(settledRatio, 0, 1) * 0.34 +
clamp(settled / Math.max(1, mobile + 3), 0, 3) * 0.12 +
urbanCore;
return clamp(score, 0, 2.2);
}
maybeAbsorbNomadIntoUrbanRegion(band, urbanPressure = this.nomadUrbanPressure(band)) {
const cfg = SimConfig.nomad || {};
const radius = urbanPressure > 1.1 ? 12 : 10;
const city = this.findCityNear(Math.round(band.x), Math.round(band.y), radius);
if (!city) {
band.targetTile = null;
band.mode = "leaving";
band.cohesion = clamp(band.cohesion - urbanPressure * 0.008, 0, 1);
return false;
}
const absorbRate = clamp(0.08 + (urbanPressure - (cfg.urbanAbsorptionControl ?? 0.55)) * 0.12, 0.08, 0.25);
let migrants = Math.max(4, band.population * absorbRate);
const dissolve = band.population < 40 || urbanPressure > 1.35;
if (dissolve) migrants = band.population;
this.transferNomadsToCity(band, city, migrants, "urbanAbsorption");
if (dissolve || band.population < 25) this.nomadBands = this.nomadBands.filter(candidate => candidate !== band);
return true;
}
transferNomadsToCity(band, city, migrants, reason = "sedentarization") {
if (!band || !city || migrants <= 0 || band.population <= 0) return 0;
const moved = Math.min(band.population, migrants);
const share = moved / Math.max(1, band.population);
band.population -= moved;
band.herds = Math.max(0, band.herds * (1 - share * 0.82));
const resourceTransfer = band.resources * share * 0.45;
band.resources = Math.max(0, band.resources - resourceTransfer);
city.population += moved;
city.storedResources += resourceTransfer;
city.ethnicityComposition.set(band.ethnicityId, (city.ethnicityComposition.get(band.ethnicityId) || 0) + moved);
const tile = this.world.idx(city.x, city.y);
this.world.population[tile] += moved * 0.15;
this.world.settledPopulation[tile] += moved;
this.markPopulationTile(tile);
this.addNomadTrace(tile, band.ethnicityId, moved * 0.10);
band.sedentarization = clamp((band.sedentarization || 0) + 0.05, 0, 1);
band.mode = "settling";
if (this.year - (band.lastUrbanAbsorptionYear ?? -Infinity) > years(18) && (moved >= 30 || reason === "sedentarization")) {
this.addGraphEvent("sedentarization", this.year, { bandId: band.id, cityId: city.id, population: Math.round(moved), reason }, 1);
band.lastUrbanAbsorptionYear = this.year;
}
return moved;
}
interactNomadsWithCities(band) {
const cfg = SimConfig.nomad || {};
const cities = this.getCitiesNear(Math.round(band.x), Math.round(band.y), cfg.cityInteractionRadius ?? 8);
if (!cities.length) return;
for (const city of cities.slice(0, 3)) {
const exposed = city.polityId === null ? 0.7 : clamp(1 - (this.getPolityById(city.polityId)?.cohesion ?? 0.6), 0, 0.8) + clamp(1 - city.loyalty, 0, 1) * 0.5;
const hungry = band.resources < band.population * 0.08 ? 0.25 : 0;
if (this.year - (band.lastTradeYear ?? -Infinity) > years(6) && this.rng.next() < (cfg.tradeChance ?? 0.22) * band.tradeAffinity) {
this.nomadTradeWithCity(band, city);
}
if (this.year - (band.lastRaidYear ?? -Infinity) > years(8) && this.rng.next() < (cfg.raidChance ?? 0.14) * (band.raidAffinity + hungry + exposed * 0.4)) {
this.nomadRaidCity(band, city, false);
}
if (this.rng.next() < (cfg.sedentarizationChance ?? 0.035) * band.sedentarization) this.sedentarizeNomadsIntoCity(band, city);
}
}
nomadTradeWithCity(band, city) {
const value = Math.min(city.storedResources * 0.05, Math.max(2, band.population * 0.025));
city.storedResources += value * 0.35;
band.resources += value;
band.prestige = clamp(band.prestige + 0.025, 0, 5);
band.mode = "trading";
band.lastTradeYear = this.year;
const path = this.findTerrainRoute(Math.round(band.x), Math.round(band.y), city.x, city.y);
if (this.isValidRoutePath(path, city.x, city.y)) this.depositRoutePheromone(path, 0.9);
this.addGraphEvent("nomadTrade", this.year, { bandId: band.id, cityId: city.id }, 1);
}
nomadRaidCity(band, city, severe = false) {
const loot = Math.min(city.storedResources * (severe ? 0.38 : 0.18), band.population * (severe ? 0.16 : 0.08));
city.storedResources = Math.max(0, city.storedResources - loot);
const loss = Math.min(city.population * (severe ? 0.10 : 0.035), band.population * 0.035);
if (loss > 0) {
city.population = Math.max(0, city.population - loss);
removeFromComposition(city.ethnicityComposition, loss);
}
city.loyalty = clamp((city.loyalty ?? 0.5) - (severe ? 0.10 : 0.035), 0, 1);
if (city.polityId !== null) {
const polity = this.getPolityById(city.polityId);
if (polity) {
polity.cohesion = clamp((polity.cohesion ?? 0.6) - (severe ? 0.035 : 0.012), 0, 1);
polity.treasury = Math.max(0, (polity.treasury || 0) - loot * 0.18);
}
}
band.resources += loot;
band.prestige = clamp(band.prestige + (severe ? 0.18 : 0.06), 0, 5);
band.mode = severe ? "invading" : "raiding";
band.lastRaidYear = this.year;
this.addGraphEvent("nomadRaid", this.year, { bandId: band.id, cityId: city.id, severe }, severe ? 2 : 1);
}
sedentarizeNomadsIntoCity(band, city) {
if (band.population < 60) return;
const migrants = Math.min(band.population * this.rng.range(0.08, 0.22), Math.max(8, city.population * 0.12));
this.transferNomadsToCity(band, city, migrants);
}
maybeNomadRaidOrInvasion(band) {
const cfg = SimConfig.nomad || {};
if (band.population < (cfg.confederationPopulation ?? 900) * 0.55) return;
if (band.cohesion < 0.55 || band.prestige < 1.0 || band.aggression < 0.55) return;
if (this.rng.next() > (cfg.invasionChance ?? 0.025) * band.aggression * band.raidAffinity) return;
const cities = this.getCitiesNear(Math.round(band.x), Math.round(band.y), cfg.cityInteractionRadius ?? 8)
.filter(city => city.population > 20)
.sort((a, b) => this.nomadCityWeaknessScore(band, b) - this.nomadCityWeaknessScore(band, a));
if (cities.length) this.resolveNomadInvasion(band, cities[0]);
}
nomadCityWeaknessScore(band, city) {
const polity = city.polityId !== null ? this.getPolityById(city.polityId) : null;
const crisis = polity ? (polity.crisis || 0) + clamp(1 - (polity.cohesion ?? 0.6), 0, 1) : 0.7;
return crisis + clamp(1 - (city.loyalty ?? 0.5), 0, 1) + clamp((city.storedResources || 0) / 160, 0, 1.2) - Math.sqrt(city.population || 1) / 80;
}
resolveNomadInvasion(band, city) {
const score = band.population * band.cohesion * (0.5 + band.prestige * 0.25) * band.aggression;
const defense = Math.max(20, city.population * (0.55 + (city.loyalty ?? 0.5)) + (city.polityId !== null ? this.polityPower(this.getPolityById(city.polityId)) * 0.6 : 0));
if (this.rng.next() > clamp(score / Math.max(1, score + defense), 0.08, 0.72)) {
band.cohesion = clamp(band.cohesion - 0.08, 0, 1);
band.resources = Math.max(0, band.resources - band.population * 0.06);
return;
}
const outcomeRoll = this.rng.next();
if (outcomeRoll < 0.45) {
this.nomadRaidCity(band, city, true);
} else if (outcomeRoll < 0.75) {
const oldPolityId = city.polityId;
if (city.polityId !== null) this.removeCityFromPolity(city);
this.releaseCityTerritory(city, oldPolityId, 5, "nomadConquest");
2026-09-16 20:53:17 +09:00
this.protectCityFromRebellion(city);
2026-09-16 13:51:43 +09:00
city.loyalty = 0.22;
band.prestige = clamp(band.prestige + 0.28, 0, 5);
this.addGraphEvent("nomadConquest", this.year, { bandId: band.id, cityId: city.id, outcome: "puppetIndependent" }, 3);
} else {
const oldPolityId = city.polityId;
if (city.polityId !== null) this.removeCityFromPolity(city);
this.releaseCityTerritory(city, oldPolityId, 5, "nomadConquest");
2026-09-16 20:53:17 +09:00
this.protectCityFromRebellion(city);
2026-09-16 13:51:43 +09:00
const settlers = Math.min(band.population * 0.28, Math.max(30, city.population * 0.35));
band.population -= settlers;
city.population += settlers;
city.ethnicityComposition.set(band.ethnicityId, (city.ethnicityComposition.get(band.ethnicityId) || 0) + settlers);
const polity = this.createPolity(city);
if (polity) {
polity.origin = "nomad";
polity.type = "nomadic_dynasty";
}
band.prestige = clamp(band.prestige + 0.45, 0, 5);
band.mode = "invading";
this.addGraphEvent("nomadConquest", this.year, { bandId: band.id, cityId: city.id, outcome: "nomadicDynasty" }, 4);
}
}
maybeFormNomadConfederation(band) {
const cfg = SimConfig.nomad || {};
if (band.population < (cfg.confederationPopulation ?? 900) || band.prestige < (cfg.confederationPrestige ?? 1.8) || band.cohesion < 0.58) return;
band.mode = "confederating";
band.radius = Math.min((cfg.maxRadius ?? 14) + 8, band.radius + 1);
band.charisma = clamp(band.charisma + 0.05, 0.5, 2.4);
for (const other of [...this.nomadBands]) {
if (other === band || other.population > band.population * 0.55) continue;
const distance = Math.abs(other.x - band.x) + Math.abs(other.y - band.y);
if (distance > band.radius + other.radius + 8) continue;
if (this.rng.next() < 0.18 * band.charisma) this.mergeNomadBands(band, other);
}
this.addGraphEvent("nomadConfederation", this.year, { bandId: band.id, population: Math.round(band.population) }, 2);
}
mergeNomadBands(leaderBand, subordinateBand) {
leaderBand.population += subordinateBand.population;
leaderBand.herds += subordinateBand.herds;
leaderBand.resources += subordinateBand.resources * 0.7;
leaderBand.prestige = clamp(leaderBand.prestige + subordinateBand.prestige * 0.18, 0, 5);
leaderBand.cohesion = clamp((leaderBand.cohesion + subordinateBand.cohesion) * 0.48, 0, 1);
this.nomadBands = this.nomadBands.filter(band => band !== subordinateBand);
this.sanitizeNomadBand(leaderBand);
}
maybeMergeWeakNomadBand(band) {
if (!this.nomadBands.includes(band) || band.population <= 0) return false;
let strongest = null;
let strongestDistance = Infinity;
for (const other of this.nomadBands) {
if (other === band || other.population <= band.population) continue;
const distance = Math.abs(other.x - band.x) + Math.abs(other.y - band.y);
if (distance > band.radius + other.radius + 6) continue;
if (band.population >= other.population * 0.45) continue;
const sameEthnicity = other.ethnicityId === band.ethnicityId;
const compatible = sameEthnicity || Math.abs((other.sedentarization || 0) - (band.sedentarization || 0)) < 0.18 || Math.abs((other.aggression || 0) - (band.aggression || 0)) < 0.22;
if (!compatible) continue;
if (distance < strongestDistance) {
strongest = other;
strongestDistance = distance;
}
}
if (!strongest) return false;
const important = band.population >= 120 || strongest.population + band.population >= (SimConfig.nomad.confederationPopulation ?? 900);
this.mergeNomadBands(strongest, band);
if (important) this.addGraphEvent("nomadMerge", this.year, { bandId: strongest.id, mergedBandId: band.id, population: Math.round(strongest.population) }, 1);
return true;
}
splitNomadBand(band) {
const cfg = SimConfig.nomad || {};
if (this.nomadBands.length >= (cfg.maxBands ?? 24)) return null;
if (this.year - (band.lastSplitYear ?? -Infinity) < years(60)) return null;
if (band.population < (cfg.splitPopulation ?? 1200) && band.cohesion > 0.22) return null;
const split = band.population * this.rng.range(0.24, 0.42);
if (split < 80) return null;
band.population -= split;
band.herds *= 0.72;
band.resources *= 0.78;
band.cohesion = clamp(band.cohesion - 0.10, 0, 1);
band.lastSplitYear = this.year;
const tile = this.world.idx(Math.round(band.x), Math.round(band.y));
const child = this.spawnNomadBand(tile, {
ethnicityId: band.ethnicityId,
population: split,
herds: band.herds * 0.35,
resources: band.resources * 0.25
2026-05-14 14:38:34 +09:00
});
2026-09-16 13:51:43 +09:00
if (child) {
child.mode = "roaming";
child.prestige = Math.max(0.1, band.prestige * 0.35);
this.addGraphEvent("nomadSplit", this.year, { bandId: band.id, newBandId: child.id, population: Math.round(split) }, 1);
}
return child;
2026-05-14 14:38:34 +09:00
}
2026-05-13 20:21:58 +09:00
step() {
for (const city of this.cities) city.activeVisitors = 0;
this.dominantEthnicityCache.clear();
2026-09-16 13:51:43 +09:00
this.rebuildIndexes();
2026-09-16 20:53:17 +09:00
const annualTick = this.year % years(1) === 0;
2026-09-17 22:32:25 +09:00
const weekOfYear = this.year % WEEKS_PER_YEAR;
2026-09-16 20:53:17 +09:00
const fieldTick = this.year % WEEKS_PER_MONTH === 0;
2026-09-17 22:32:25 +09:00
const resourceTick = weekOfYear === WEEKS_PER_YEAR - 1;
// Pheromone changes update capacity only on touched cells inside updateWorldFields().
// This avoids a full-map capacity pass every simulated month.
if (fieldTick) this.updateWorldFields(WEEKS_PER_MONTH);
// Resources are consumed by the annual economy. Batch the same 48-week
// regeneration immediately before the next annual tick.
if (resourceTick) this.regenerateResources(WEEKS_PER_YEAR);
2026-05-13 20:21:58 +09:00
this.maybeSpawnDisaster();
2026-09-17 22:32:25 +09:00
if (annualTick) this.maybeSpawnFrontierWave();
if (annualTick) {
2026-05-13 20:21:58 +09:00
this.updateCities();
2026-09-16 13:51:43 +09:00
this.updateNomadBands();
2026-05-17 21:08:03 +09:00
this.consumeTileResources();
this.growTilePopulation();
2026-09-16 20:53:17 +09:00
this.updatePopulationPressure();
2026-09-16 13:51:43 +09:00
this.updateNomadInfluence();
2026-09-17 22:32:25 +09:00
this.clearSparseHumanData();
2026-09-16 13:51:43 +09:00
if (this.year % years(100) === 0 && typeof console !== "undefined" && console.debug) {
const d = this.territoryDebug;
if (d.disconnectedTerritoryRemoved || d.independentCitiesAbsorbedByTerritory || d.cityTerritoryMismatchesFixed || d.invalidCampaignTargetsRejected) {
console.debug("territory topology", { year: Math.floor(this.year / WEEKS_PER_YEAR), ...d });
}
}
2026-05-13 20:21:58 +09:00
}
2026-09-17 22:32:25 +09:00
// Spread expensive annual/periodic field work across the year instead of
// stacking it all on the year boundary. Each population cell still migrates
// once per year, in one of four stable quarterly cohorts.
if (weekOfYear % 12 === 8) {
const cohort = Math.floor(weekOfYear / 12) % 4;
this.updatePopulationCapacity();
this.diffusePopulation(cohort, 4);
this.updatePopulationPressure();
2026-05-13 20:21:58 +09:00
}
2026-09-17 22:32:25 +09:00
if (this.year % years(2) === 12) {
this.updateTileTechnology();
this.diffuseTileTechnology();
this.cityTechnologyExchange();
this.clearSparseHumanData();
}
if (this.year % years(3) === 24) this.updateTileAssimilation();
if (weekOfYear === 36) this.spreadCulturesFromCitiesAndTrade();
if (this.year % years(5) === 20) this.updateTradeRoutes(true);
// State accounting follows the annual city economy, but is offset by one
// month so the two largest annual workloads do not land on the same frame.
if (weekOfYear === 4) this.updatePolities(true);
2026-09-16 20:53:17 +09:00
if (this.year % SimConfig.war.tickWeeks === 0) {
this.maybeStartWars();
this.updateCampaigns();
this.updateWars();
}
const historyTick = this.year % 12 === 0;
if (historyTick) this.updateEthnicStats();
2026-09-17 22:32:25 +09:00
// Ethnic maintenance is annual in frequency but offset from the year boundary.
if (this.year > 0 && weekOfYear === 40) {
this.updateEthnicStats();
2026-09-16 20:53:17 +09:00
this.absorbSmallEthnicities();
2026-05-13 20:21:58 +09:00
this.updateEthnicStats();
this.splitDivergentEthnicities();
this.updateEthnicStats();
}
2026-09-16 20:53:17 +09:00
if (historyTick) this.sampleWorldHistory();
2026-05-13 20:21:58 +09:00
this.year++;
}
rebuildIndexes() {
2026-09-16 13:51:43 +09:00
this.cityById.clear();
2026-09-17 22:32:25 +09:00
this.polityCitiesCache?.clear();
2026-09-16 13:51:43 +09:00
for (const city of this.cities) this.cityById.set(city.id, city);
this.polityById.clear();
for (const polity of this.polities) this.polityById.set(polity.id, polity);
this.cityBuckets.clear();
2026-05-13 20:21:58 +09:00
for (const city of this.cities) {
const key = this.cityBucketKey(city.x, city.y);
let bucket = this.cityBuckets.get(key);
if (!bucket) {
bucket = [];
this.cityBuckets.set(key, bucket);
}
bucket.push(city);
}
}
cityBucketKey(x, y) {
2026-09-16 13:51:43 +09:00
return Math.floor(x / 8) + Math.floor(y / 8) * 4096;
2026-05-13 20:21:58 +09:00
}
getCitiesNear(x, y, radius) {
const cities = [];
const minBx = Math.floor((x - radius) / 8);
const maxBx = Math.floor((x + radius) / 8);
const minBy = Math.floor((y - radius) / 8);
const maxBy = Math.floor((y + radius) / 8);
for (let by = minBy; by <= maxBy; by++) {
for (let bx = minBx; bx <= maxBx; bx++) {
2026-09-16 13:51:43 +09:00
const bucket = this.cityBuckets.get(bx + by * 4096);
2026-05-13 20:21:58 +09:00
if (!bucket) continue;
for (const city of bucket) {
if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city);
}
}
}
return cities;
}
2026-09-16 13:51:43 +09:00
seedInitialPopulationFieldDirectly(initialPopulation) {
2026-05-17 21:08:03 +09:00
const w = this.world;
w.population.fill(0);
w.settledPopulation.fill(0);
w.mobilePopulation.fill(0);
w.populationPressure.fill(0);
this.tileEthnicMix.clear();
2026-09-16 13:51:43 +09:00
this.activePopulationTiles.clear();
2026-09-16 20:53:17 +09:00
const founders = Math.max(6, Math.min(18, Math.round(initialPopulation / (650 * SimConfig.population.populationScale))));
2026-09-16 13:51:43 +09:00
const desertFounders = Math.max(2, Math.floor(founders * 0.25));
for (let e = 0; e < founders; e++) {
const desertFounder = e < desertFounders;
const origin = this.findFounderRegionTile(desertFounder);
const id = this.createEthnicity(0, {
temperature: w.temperature[origin],
humidity: w.humidity[origin]
});
const traits = desertFounder ? this.desertFounderTraits() : this.randomTraits();
const ethnicity = this.ethnicities.get(id);
if (ethnicity) ethnicity.averageTraits = traits;
const share = initialPopulation / founders * this.rng.range(0.72, 1.28);
this.seedEthnicPopulationPatch(origin, id, traits, share, desertFounder ? 5 : 4);
}
this.syncTilePopulationCulture();
this.updatePopulationCapacity();
this.populationFieldInitialized = true;
}
findFounderRegionTile(desertFounder = false) {
const w = this.world;
let best = 0;
let bestScore = -Infinity;
const tries = Math.max(120, Math.floor(w.size * 1.8));
for (let n = 0; n < tries; n++) {
const tile = this.rng.int(w.count);
if (w.terrain[tile] === Terrain.WATER) continue;
const water = this.localWaterScore(tile, 5);
const desertFit = desertFounder ? (w.terrain[tile] === Terrain.DESERT ? 1.3 : -0.6) : (w.terrain[tile] === Terrain.DESERT ? -0.4 : 0);
const score =
w.fertility[tile] * 2.2 +
clamp(w.resource[tile] / 24, 0, 1.6) +
w.mineral[tile] * 0.45 +
water * 0.9 -
w.move[tile] * 0.55 +
desertFit +
this.rng.range(0, 0.55);
if (score > bestScore) {
best = tile;
bestScore = score;
}
}
if (bestScore === -Infinity) {
const fallback = this.findHabitableTile(desertFounder ? Terrain.DESERT : null);
return w.idx(fallback.x, fallback.y);
}
return best;
}
localWaterScore(tile, radius = 4) {
const w = this.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let score = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const d = Math.abs(dx) + Math.abs(dy);
if (!d || d > radius) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
if (w.terrain[w.idx(tx, ty)] === Terrain.WATER) score += (radius + 1 - d) / radius;
}
}
return score;
}
seedEthnicPopulationPatch(centerTile, ethnicityId, traits, totalPopulation, radius = 4) {
const w = this.world;
const cx = centerTile % w.size;
const cy = Math.floor(centerTile / w.size);
const tiles = [];
let weightTotal = 0;
for (const offset of this.getRadiusOffsets(radius)) {
const dx = offset.dx;
const dy = offset.dy;
const d = offset.distance;
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const suitability = 0.35 + w.fertility[tile] + clamp(w.resource[tile] / 30, 0, 1) * 0.6 + w.mineral[tile] * 0.2;
const weight = Math.max(0.05, suitability * (radius - d + 1));
tiles.push({ tile, weight });
weightTotal += weight;
}
const settledShare = clamp(traits.sedentary ?? 0.45, 0.08, 0.9);
for (const entry of tiles) {
const amount = totalPopulation * entry.weight / Math.max(0.001, weightTotal);
if (amount <= 0.01) continue;
w.population[entry.tile] += amount;
w.settledPopulation[entry.tile] += amount * settledShare;
w.mobilePopulation[entry.tile] += amount * (1 - settledShare);
this.markPopulationTile(entry.tile);
let mix = this.tileEthnicMix.get(entry.tile);
2026-05-17 21:08:03 +09:00
if (!mix) {
mix = new Map();
2026-09-16 13:51:43 +09:00
this.tileEthnicMix.set(entry.tile, mix);
2026-05-17 21:08:03 +09:00
}
2026-09-16 13:51:43 +09:00
mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount);
this.updateCultureTile(entry.tile);
2026-05-17 21:08:03 +09:00
}
}
syncTilePopulationCulture() {
const w = this.world;
this.tileCultures = this.tileEthnicMix;
for (const tile of this.tileEthnicMix.keys()) this.updateCultureTile(tile);
2026-09-16 13:51:43 +09:00
this.rebuildActivePopulationTiles();
for (const tile of this.activePopulationTiles) {
if (w.population[tile] <= 0 && !this.tileEthnicMix.has(tile)) {
w.dominantEthnicity[tile] = -1;
w.cultureDiversity[tile] *= 0.985;
2026-05-17 21:08:03 +09:00
}
}
}
projectCityPopulationToTiles(city) {
if (!city || city.population <= 0) return;
const w = this.world;
const radius = clamp(Math.ceil((city.agriculturalRadius || 2) * 0.75), 1, 8);
let totalWeight = 0;
const tiles = [];
2026-09-16 13:51:43 +09:00
for (const offset of this.getRadiusOffsets(radius)) {
const dx = offset.dx;
const dy = offset.dy;
const distance = offset.distance;
2026-05-17 21:08:03 +09:00
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const weight = (radius - distance + 1) * (tile === w.idx(city.x, city.y) ? 1.8 : 1);
tiles.push({ tile, weight });
totalWeight += weight;
}
if (!tiles.length || totalWeight <= 0) return;
const cityTotal = compositionTotal(city.ethnicityComposition);
const fallbackEthnicity = dominantComposition(city.ethnicityComposition);
for (const entry of tiles) {
const share = entry.weight / totalWeight;
const localPopulation = city.population * share;
w.population[entry.tile] += localPopulation;
w.settledPopulation[entry.tile] += localPopulation;
2026-09-16 13:51:43 +09:00
this.markPopulationTile(entry.tile);
2026-05-17 21:08:03 +09:00
let culture = this.tileEthnicMix.get(entry.tile);
if (!culture) {
culture = new Map();
this.tileEthnicMix.set(entry.tile, culture);
}
if (cityTotal > 0) {
for (const [id, count] of city.ethnicityComposition) {
const amount = localPopulation * count / cityTotal;
if (amount > 0) culture.set(id, (culture.get(id) || 0) + amount);
}
} else if (fallbackEthnicity !== null) {
culture.set(fallbackEthnicity, (culture.get(fallbackEthnicity) || 0) + localPopulation);
}
}
}
2026-05-13 20:21:58 +09:00
updateCultureTile(tile) {
2026-09-17 22:32:25 +09:00
const w = this.world;
const sparseThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
if ((w.population[tile] || 0) < sparseThreshold) {
w.dominantEthnicity[tile] = -1;
w.cultureDiversity[tile] = 0;
w.farmingKnowledge[tile] = 0;
w.metallurgyKnowledge[tile] = 0;
this.tileEthnicMix.delete(tile);
return;
}
2026-09-16 13:51:43 +09:00
const counts = this.tileEthnicMix.get(tile);
2026-05-13 20:21:58 +09:00
if (!counts || !counts.size) {
2026-09-17 22:32:25 +09:00
w.dominantEthnicity[tile] = -1;
w.cultureDiversity[tile] = 0;
2026-05-13 20:21:58 +09:00
return;
}
let total = 0;
let dominant = -1;
let dominantCount = 0;
for (const [id, count] of counts) {
total += count;
if (count > dominantCount) {
dominant = id;
dominantCount = count;
}
}
this.world.dominantEthnicity[tile] = dominant;
this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0;
}
2026-09-17 22:32:25 +09:00
diffusePopulation(cohortIndex = 0, cohortCount = 1) {
2026-05-17 21:08:03 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
const moves = this.populationMoveScratch;
moves.length = 0;
const tiles = this.activePopulationSnapshot();
for (const tile of tiles) {
2026-09-17 22:32:25 +09:00
if (cohortCount > 1 && tile % cohortCount !== cohortIndex) continue;
2026-05-17 21:08:03 +09:00
const population = w.population[tile];
2026-09-16 13:51:43 +09:00
const mobile = w.mobilePopulation[tile] || 0;
if (population < 1) continue;
const pressure = w.populationPressure[tile] || 0;
if (pressure <= 1.05 && (population <= 3 || mobile <= 0.5)) continue;
2026-05-17 21:08:03 +09:00
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let bestTile = -1;
let bestScore = -Infinity;
2026-09-16 13:51:43 +09:00
const radius = mobile > population * 0.35 || mobile > 3 ? 3 : 2;
for (const { dx, dy } of this.getRadiusOffsets(radius, false)) {
2026-05-17 21:08:03 +09:00
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
const target = w.idx(tx, ty);
const score = this.populationDestinationScore(tile, target);
if (score > bestScore) {
bestScore = score;
bestTile = target;
}
}
if (bestTile < 0 || bestScore <= 0) continue;
const excess = Math.max(0, population - w.populationCapacity[tile]);
2026-09-16 13:51:43 +09:00
const frontierPush = mobile > 0.5 ? mobile * 0.012 : 0;
const excessPush = excess * 0.18;
const amount = Math.min(population * 0.055, excessPush + frontierPush);
2026-05-17 21:08:03 +09:00
if (amount >= 0.05) moves.push({ from: tile, to: bestTile, amount });
}
for (const move of moves) this.moveEthnicPopulation(move.from, move.to, move.amount);
}
populationDestinationScore(fromTile, toTile) {
const w = this.world;
if (w.terrain[toTile] === Terrain.WATER) return -Infinity;
const fromDominant = w.dominantEthnicity[fromTile];
const toDominant = w.dominantEthnicity[toTile];
const sameEthnicity = fromDominant >= 0 && toDominant >= 0 && fromDominant === toDominant ? 1.1 : 0;
2026-09-16 13:51:43 +09:00
const frontier = (w.polity[toTile] < 0 ? 0.65 : 0) + ((w.population[toTile] || 0) < 1 ? 0.25 : 0);
2026-05-17 21:08:03 +09:00
const routeScore = (w.tradeRoute[toTile] ? 1.25 : 0) + clamp(w.pheromone[toTile] / 8, 0, 1.4);
const overcapacity = Math.max(0, w.populationPressure[toTile] - 0.85);
2026-09-16 13:51:43 +09:00
const foreignPolity = w.polity[toTile] >= 0 && w.polity[fromTile] >= 0 && w.polity[toTile] !== w.polity[fromTile] ? 1.25 + clamp(w.control[toTile], 0, 1) * 1.1 : 0;
2026-05-17 21:08:03 +09:00
return w.resource[toTile] * 0.06 +
w.fertility[toTile] * 1.4 +
w.mineral[toTile] * 0.35 +
w.cityPull[toTile] * 1.3 +
routeScore +
2026-09-16 13:51:43 +09:00
sameEthnicity +
frontier -
2026-05-17 21:08:03 +09:00
w.move[toTile] * 0.62 -
overcapacity * 2.4 -
foreignPolity;
}
moveEthnicPopulation(fromTile, toTile, amount) {
const w = this.world;
const fromMix = this.tileEthnicMix.get(fromTile);
if (!fromMix || amount <= 0 || w.population[fromTile] <= 0) return 0;
const moved = Math.min(amount, w.population[fromTile]);
let toMix = this.tileEthnicMix.get(toTile);
if (!toMix) {
toMix = new Map();
this.tileEthnicMix.set(toTile, toMix);
}
const total = compositionTotal(fromMix);
if (total <= 0) return 0;
for (const [id, count] of [...fromMix]) {
const ethnicMove = Math.min(count, moved * count / total);
if (ethnicMove <= 0) continue;
const remaining = count - ethnicMove;
if (remaining > 0.01) fromMix.set(id, remaining);
else fromMix.delete(id);
toMix.set(id, (toMix.get(id) || 0) + ethnicMove);
}
if (!fromMix.size) this.tileEthnicMix.delete(fromTile);
const settledShare = w.population[fromTile] > 0 ? w.settledPopulation[fromTile] / w.population[fromTile] : 0;
const settledMove = moved * clamp(settledShare, 0, 1);
const mobileMove = moved - settledMove;
w.population[fromTile] = Math.max(0, w.population[fromTile] - moved);
w.population[toTile] += moved;
w.settledPopulation[fromTile] = Math.max(0, w.settledPopulation[fromTile] - settledMove);
w.mobilePopulation[fromTile] = Math.max(0, w.mobilePopulation[fromTile] - mobileMove);
w.settledPopulation[toTile] += settledMove;
w.mobilePopulation[toTile] += mobileMove;
2026-09-16 13:51:43 +09:00
this.markPopulationTile(toTile);
2026-05-17 21:08:03 +09:00
this.updateCultureTile(fromTile);
this.updateCultureTile(toTile);
2026-09-16 13:51:43 +09:00
this.cleanupPopulationTile(fromTile);
2026-05-17 21:08:03 +09:00
return moved;
}
growTilePopulation() {
const w = this.world;
2026-09-16 13:51:43 +09:00
const tiles = this.activePopulationSnapshot();
for (const tile of tiles) {
2026-05-17 21:08:03 +09:00
const population = w.population[tile];
if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue;
const pressure = w.populationPressure[tile];
const baseGrowth = 0.004 + w.fertility[tile] * 0.010 + w.farmland[tile] * 0.007 + clamp(w.resource[tile] / 40, 0, 1) * 0.004;
const pressurePenalty = pressure > 1 ? (pressure - 1) * 0.020 : 0;
const warPenalty = w.contested[tile] ? 0.006 : 0;
const rate = clamp(baseGrowth - pressurePenalty - warPenalty, -0.045, 0.035);
const change = population * rate;
if (Math.abs(change) < 0.01) continue;
if (change > 0) this.addEthnicPopulationProportionally(tile, change);
else this.removeEthnicPopulationProportionally(tile, -change);
}
}
addEthnicPopulationProportionally(tile, amount) {
const w = this.world;
let mix = this.tileEthnicMix.get(tile);
if (!mix) {
const dominant = w.dominantEthnicity[tile];
if (dominant < 0) return;
mix = new Map([[dominant, amount]]);
this.tileEthnicMix.set(tile, mix);
w.population[tile] += amount;
w.settledPopulation[tile] += amount;
2026-09-16 13:51:43 +09:00
this.markPopulationTile(tile);
2026-05-17 21:08:03 +09:00
this.updateCultureTile(tile);
return;
}
const total = Math.max(0.001, compositionTotal(mix));
for (const [id, count] of [...mix]) mix.set(id, count + amount * count / total);
w.population[tile] += amount;
w.settledPopulation[tile] += amount;
2026-09-16 13:51:43 +09:00
this.markPopulationTile(tile);
2026-05-17 21:08:03 +09:00
this.updateCultureTile(tile);
}
removeEthnicPopulationProportionally(tile, amount) {
const w = this.world;
const mix = this.tileEthnicMix.get(tile);
2026-09-16 13:51:43 +09:00
if (!mix) return 0;
2026-05-17 21:08:03 +09:00
const removed = Math.min(amount, w.population[tile]);
const total = Math.max(0.001, compositionTotal(mix));
for (const [id, count] of [...mix]) {
const loss = removed * count / total;
const remaining = count - loss;
if (remaining > 0.01) mix.set(id, remaining);
else mix.delete(id);
}
if (!mix.size) this.tileEthnicMix.delete(tile);
const settledShare = w.population[tile] > 0 ? w.settledPopulation[tile] / w.population[tile] : 0;
w.population[tile] = Math.max(0, w.population[tile] - removed);
w.settledPopulation[tile] = Math.max(0, w.settledPopulation[tile] - removed * settledShare);
w.mobilePopulation[tile] = Math.max(0, w.mobilePopulation[tile] - removed * (1 - settledShare));
this.updateCultureTile(tile);
2026-09-16 13:51:43 +09:00
this.cleanupPopulationTile(tile);
return removed;
2026-05-17 21:08:03 +09:00
}
consumeTileResources() {
const w = this.world;
2026-09-16 13:51:43 +09:00
const tiles = this.activePopulationSnapshot();
for (const tile of tiles) {
2026-05-17 21:08:03 +09:00
const population = w.population[tile];
if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue;
const pressure = w.populationPressure[tile] || 0;
2026-09-16 20:53:17 +09:00
const demand = (population / SimConfig.population.populationScale) * (0.0045 + Math.max(0, pressure - 1) * 0.0035);
2026-05-17 21:08:03 +09:00
w.resource[tile] = Math.max(0, w.resource[tile] - demand);
}
}
2026-09-17 22:32:25 +09:00
clearSparseHumanData() {
const w = this.world;
const threshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
if (threshold <= 0) return 0;
let cleared = 0;
for (const tile of this.activePopulationSnapshot()) {
if ((w.population[tile] || 0) >= threshold) continue;
const hadData = w.farmingKnowledge[tile] !== 0 || w.metallurgyKnowledge[tile] !== 0 ||
w.dominantEthnicity[tile] >= 0 || w.cultureDiversity[tile] !== 0 || this.tileEthnicMix.has(tile);
if (!hadData) continue;
w.farmingKnowledge[tile] = 0;
w.metallurgyKnowledge[tile] = 0;
w.dominantEthnicity[tile] = -1;
w.cultureDiversity[tile] = 0;
this.tileEthnicMix.delete(tile);
cleared++;
}
return cleared;
}
2026-05-17 21:08:03 +09:00
updateTileAssimilation() {
const w = this.world;
for (const [tile, mix] of [...this.tileEthnicMix]) {
if (!mix || mix.size <= 1) continue;
const total = compositionTotal(mix);
if (total <= 0.5) continue;
let dominant = null;
let dominantCount = 0;
for (const [id, count] of mix) {
if (count > dominantCount) {
dominant = id;
dominantCount = count;
}
}
if (dominant == null || dominantCount <= 0) continue;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
const nearCity = this.getCitiesNear(x, y, 5).length ? 1 : 0;
const control = clamp(w.control[tile] || 0, 0, 1);
const route = w.tradeRoute[tile] ? 1 : 0;
const diversity = clamp(w.cultureDiversity[tile] || 0, 0, 1);
const dominantTraits = this.ethnicities.get(dominant)?.averageTraits;
const assimilation = dominantTraits?.assimilation ?? 0.12;
const baseRate = (0.0004 + nearCity * 0.00045 + control * 0.00035 + route * 0.00035 + diversity * 0.00025) * (0.5 + assimilation);
let gained = 0;
for (const [id, count] of [...mix]) {
if (id === dominant || count <= 0) continue;
const minorityTraits = this.ethnicities.get(id)?.averageTraits;
const resistance = minorityTraits?.ethnocentrism ?? 0.35;
const converted = Math.min(count, count * baseRate * clamp(1.15 - resistance * 0.55, 0.2, 1.15));
if (converted <= 0.001) continue;
const remaining = count - converted;
if (remaining > 0.01) mix.set(id, remaining);
else mix.delete(id);
gained += converted;
}
if (gained > 0) {
mix.set(dominant, (mix.get(dominant) || 0) + gained);
this.updateCultureTile(tile);
}
}
}
2026-09-16 20:53:17 +09:00
blendEthnicComposition(target, targetTotal, source, rate) {
if (!target || !source?.size || targetTotal <= 0 || rate <= 0) return false;
const sourceTotal = compositionTotal(source);
if (sourceTotal <= 0) return false;
const blendRate = clamp(rate, 0, 0.25);
const ids = new Set([...target.keys(), ...source.keys()]);
for (const id of ids) {
const current = target.get(id) || 0;
const desired = targetTotal * (source.get(id) || 0) / sourceTotal;
const next = current + (desired - current) * blendRate;
if (next > 0.01) target.set(id, next);
else target.delete(id);
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
return true;
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
blendTileEthnicComposition(tile, source, rate) {
const population = this.world.population[tile] || 0;
if (population <= 0.01 || this.world.terrain[tile] === Terrain.WATER || !source?.size) return false;
let target = this.tileEthnicMix.get(tile);
if (!target?.size) {
target = new Map();
const sourceTotal = compositionTotal(source);
if (sourceTotal <= 0) return false;
for (const [id, count] of source) target.set(id, population * count / sourceTotal);
this.tileEthnicMix.set(tile, target);
this.updateCultureTile(tile);
return true;
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
const targetTotal = compositionTotal(target);
const changed = this.blendEthnicComposition(target, targetTotal, source, rate);
if (changed) this.updateCultureTile(tile);
return changed;
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
spreadCulturesFromCitiesAndTrade() {
const w = this.world;
const cfg = SimConfig.culture || {};
const urbanRadius = cfg.urbanSpreadRadius ?? 5;
const cityRate = cfg.cityBlendRate ?? 0.045;
for (const city of this.cities) {
if (!city.ethnicityComposition?.size || city.population <= 0) continue;
const populationStrength = clamp(Math.sqrt(city.population / (SimConfig.city.populationScale || 1)) / 18, 0.35, 1.4);
for (const { dx, dy, distance } of this.getRadiusOffsets(urbanRadius)) {
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER || (w.population[tile] || 0) <= 0.01) continue;
const falloff = 1 - distance / (urbanRadius + 1);
const routeBoost = w.tradeRoute[tile] ? 1.35 : 1;
this.blendTileEthnicComposition(tile, city.ethnicityComposition, cityRate * populationStrength * falloff * routeBoost);
2026-09-16 13:51:43 +09:00
}
}
2026-09-16 20:53:17 +09:00
const routeRate = cfg.routeBlendRate ?? 0.035;
const neighborFactor = cfg.routeNeighborFactor ?? 0.35;
const cityTradeRate = cfg.cityTradeBlendRate ?? 0.015;
for (const link of this.tradeLinks || []) {
const a = this.getCityById(link.from);
const b = this.getCityById(link.to);
if (!a?.ethnicityComposition?.size || !b?.ethnicityComposition?.size) continue;
const aSource = new Map(a.ethnicityComposition);
const bSource = new Map(b.ethnicityComposition);
this.blendEthnicComposition(a.ethnicityComposition, compositionTotal(a.ethnicityComposition), bSource, cityTradeRate * (0.5 + (link.strength || 0)));
this.blendEthnicComposition(b.ethnicityComposition, compositionTotal(b.ethnicityComposition), aSource, cityTradeRate * (0.5 + (link.strength || 0)));
const routeCulture = new Map();
for (const [id, count] of aSource) routeCulture.set(id, (routeCulture.get(id) || 0) + count / Math.max(1, a.population));
for (const [id, count] of bSource) routeCulture.set(id, (routeCulture.get(id) || 0) + count / Math.max(1, b.population));
const localRate = routeRate * clamp(0.6 + (link.strength || 0), 0.6, 1.8);
for (const tile of link.path || []) {
if ((w.population[tile] || 0) > 0.01) this.blendTileEthnicComposition(tile, routeCulture, localRate);
this.forCardinalNeighbors(tile, neighbor => {
if ((w.population[neighbor] || 0) > 0.01) this.blendTileEthnicComposition(neighbor, routeCulture, localRate * neighborFactor);
});
}
2026-09-16 13:51:43 +09:00
}
2026-05-14 00:21:24 +09:00
}
2026-05-13 20:21:58 +09:00
farmabilityAt(tile) {
const t = this.world.terrain[tile];
if (t === Terrain.PLAINS) return 0.75;
if (t === Terrain.FOREST) return 0.55;
if (t === Terrain.DESERT) return 0.25;
return 0.0;
}
2026-09-16 20:53:17 +09:00
initializePopulationCapacityCache() {
2026-05-17 21:08:03 +09:00
const w = this.world;
2026-09-16 20:53:17 +09:00
const { carryingCapacityBase, carryingCapacityFertility, carryingCapacityMineral } = SimConfig.population;
const populationScale = SimConfig.population.populationScale;
2026-05-17 21:08:03 +09:00
for (let i = 0; i < w.count; i++) {
2026-09-16 13:51:43 +09:00
if (w.terrain[i] === Terrain.WATER) {
2026-09-16 20:53:17 +09:00
this.populationCapacityStatic[i] = 0.35 * populationScale;
this.populationCapacityDynamicScale[i] = 0;
2026-09-16 13:51:43 +09:00
continue;
}
const terrainFactor =
w.terrain[i] === Terrain.MOUNTAIN ? 0.62 :
w.terrain[i] === Terrain.DESERT ? 0.55 :
w.terrain[i] === Terrain.FOREST ? 0.92 :
1;
2026-09-16 20:53:17 +09:00
const scale = terrainFactor * populationScale;
this.populationCapacityStatic[i] = (
carryingCapacityBase +
w.fertility[i] * carryingCapacityFertility +
w.mineral[i] * carryingCapacityMineral
) * scale;
this.populationCapacityDynamicScale[i] = scale;
}
}
updatePopulationPressure() {
const w = this.world;
for (let i = 0; i < w.count; i++) {
const capacity = w.populationCapacity[i];
w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0;
}
}
2026-09-17 22:32:25 +09:00
updatePopulationCapacityTile(i) {
2026-09-16 20:53:17 +09:00
const w = this.world;
2026-09-17 22:32:25 +09:00
const scale = this.populationCapacityDynamicScale[i];
const staticCapacity = this.populationCapacityStatic[i];
2026-09-16 20:53:17 +09:00
if (scale === 0) {
2026-09-17 22:32:25 +09:00
w.populationCapacity[i] = staticCapacity;
w.populationPressure[i] = staticCapacity > 0 ? w.population[i] / staticCapacity : 0;
return;
2026-09-16 20:53:17 +09:00
}
2026-09-17 22:32:25 +09:00
const maxPheromone = Math.max(1, SimConfig.route.maxPheromone);
2026-09-16 20:53:17 +09:00
const routeBonus = w.tradeRoute[i] ? 1.6 : Math.min(1, Math.max(0, w.pheromone[i] / maxPheromone)) * 0.8;
2026-09-16 13:51:43 +09:00
const cityBonus = w.city[i] >= 0 ? 3.5 : w.cityPull[i] * 2.2;
2026-09-17 22:32:25 +09:00
const capacity = staticCapacity + (
w.farmland[i] * SimConfig.population.carryingCapacityFarmland + routeBonus + cityBonus
2026-09-16 20:53:17 +09:00
) * scale;
2026-05-17 21:08:03 +09:00
w.populationCapacity[i] = capacity;
2026-09-17 22:32:25 +09:00
w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0;
2026-05-17 21:08:03 +09:00
}
2026-09-17 22:32:25 +09:00
updatePopulationCapacity() {
for (let i = 0; i < this.world.count; i++) this.updatePopulationCapacityTile(i);
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
updateTileTechnology() {
2026-05-13 20:21:58 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
const tiles = this.activePopulationSnapshot();
2026-09-17 22:32:25 +09:00
const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
2026-09-16 13:51:43 +09:00
for (const tile of tiles) {
2026-09-17 22:32:25 +09:00
if (w.population[tile] < humanThreshold || w.terrain[tile] === Terrain.WATER) {
w.farmingKnowledge[tile] = 0;
w.metallurgyKnowledge[tile] = 0;
continue;
}
2026-09-16 13:51:43 +09:00
const settled = w.settledPopulation[tile] || 0;
const density = clamp(Math.log1p(settled) / Math.log(80), 0, 1.4);
const cityPull = w.city[tile] >= 0 ? 1 : clamp(w.cityPull[tile], 0, 1);
const route = w.tradeRoute[tile] ? 0.35 : clamp(w.pheromone[tile] / SimConfig.route.maxPheromone, 0, 0.25);
const farmingGain = SimConfig.technology.cityInnovation * 0.45 * density * (0.2 + this.farmabilityAt(tile) + w.farmland[tile] * 0.8 + cityPull * 0.45 + route);
const metalTerrain = w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1;
const metallurgyGain = SimConfig.technology.cityInnovation * 0.32 * density * (0.15 + w.mineral[tile] * metalTerrain + cityPull * 0.30 + route);
w.farmingKnowledge[tile] = clamp(w.farmingKnowledge[tile] + farmingGain, 0, 1);
w.metallurgyKnowledge[tile] = clamp(w.metallurgyKnowledge[tile] + metallurgyGain, 0, 1);
2026-05-14 00:21:24 +09:00
}
}
2026-09-16 13:51:43 +09:00
diffuseTileTechnology() {
2026-05-14 00:21:24 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
const nextFarming = this.scratchFloatA;
const nextMetallurgy = this.scratchFloatB;
2026-09-17 22:32:25 +09:00
const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
2026-09-16 13:51:43 +09:00
nextFarming.set(w.farmingKnowledge);
nextMetallurgy.set(w.metallurgyKnowledge);
const sources = this.technologySourceScratch;
sources.length = 0;
const marker = this.nextVisitMarker();
for (const tile of this.activePopulationTiles) {
this.visitStamp[tile] = marker;
sources.push(tile);
}
for (const tile of this.activeTradeRouteTiles) {
if (this.visitStamp[tile] === marker) continue;
this.visitStamp[tile] = marker;
sources.push(tile);
}
for (const tile of sources) {
2026-09-17 22:32:25 +09:00
if ((w.population[tile] || 0) < humanThreshold) {
nextFarming[tile] = 0;
nextMetallurgy[tile] = 0;
continue;
}
2026-09-16 13:51:43 +09:00
this.forCardinalNeighbors(tile, n => {
2026-09-17 22:32:25 +09:00
if (w.terrain[n] === Terrain.WATER || (w.population[n] || 0) < humanThreshold) {
nextFarming[n] = 0;
nextMetallurgy[n] = 0;
return;
}
2026-09-16 13:51:43 +09:00
const routeBoost = w.tradeRoute[tile] || w.tradeRoute[n] ? 1.9 : 1;
const popBoost = clamp(Math.log1p(w.population[n]) / Math.log(60), 0.2, 1.2);
const rate = clamp((0.010 + (w.cityPull[tile] + w.cityPull[n]) * 0.004) * routeBoost * popBoost, 0.002, 0.035);
nextFarming[n] = clamp(nextFarming[n] + (w.farmingKnowledge[tile] - w.farmingKnowledge[n]) * rate, 0, 1);
nextMetallurgy[n] = clamp(nextMetallurgy[n] + (w.metallurgyKnowledge[tile] - w.metallurgyKnowledge[n]) * rate, 0, 1);
2026-05-13 20:21:58 +09:00
});
}
2026-09-16 13:51:43 +09:00
w.farmingKnowledge.set(nextFarming);
w.metallurgyKnowledge.set(nextMetallurgy);
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
cityTechnologyExchange() {
2026-05-13 20:21:58 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
for (const city of this.cities) {
city.knowledge ??= { farming: 0, metallurgy: 0 };
const radius = Math.max(1, city.agriculturalRadius || 2);
let farming = 0;
let metallurgy = 0;
let total = 0;
for (const offset of this.getRadiusOffsets(radius)) {
const dx = offset.dx;
const dy = offset.dy;
const d = offset.distance;
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
const weight = (radius - d + 1) * Math.max(0.2, w.population[tile]);
farming += w.farmingKnowledge[tile] * weight;
metallurgy += w.metallurgyKnowledge[tile] * weight;
total += weight;
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
if (total > 0) {
city.knowledge.farming = clamp(Math.max(city.knowledge.farming, farming / total * 0.86), 0, 1);
city.knowledge.metallurgy = clamp(Math.max(city.knowledge.metallurgy, metallurgy / total * 0.82), 0, 1);
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
const cityTile = w.idx(city.x, city.y);
w.farmingKnowledge[cityTile] = clamp(Math.max(w.farmingKnowledge[cityTile], city.knowledge.farming * 0.72), 0, 1);
w.metallurgyKnowledge[cityTile] = clamp(Math.max(w.metallurgyKnowledge[cityTile], city.knowledge.metallurgy * 0.68), 0, 1);
for (const link of this.tradeLinks || []) {
if (link.from !== city.id && link.to !== city.id) continue;
const other = this.getCityById(link.from === city.id ? link.to : link.from);
if (!other?.knowledge) continue;
const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + (link.strength || 0.2)), 0, 0.012);
city.knowledge.farming = clamp(city.knowledge.farming + (other.knowledge.farming - city.knowledge.farming) * rate, 0, 1);
city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + (other.knowledge.metallurgy - city.knowledge.metallurgy) * rate, 0, 1);
2026-05-13 20:21:58 +09:00
}
}
}
climateMismatch(ethnicityId, tile) {
const ethnicity = this.ethnicities.get(ethnicityId);
if (!ethnicity) return 0;
const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp);
const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity);
return tempDiff * 0.58 + humidDiff * 0.42;
}
2026-09-17 22:32:25 +09:00
updateWorldFields(scale = 1) {
2026-09-16 20:53:17 +09:00
// Preserve the former biweekly pheromone diffusion semantics while keeping
// pheromone work sparse. Resource regeneration is batched separately by step().
let remaining = Math.max(0, scale);
let pheromoneChanged = false;
while (remaining > 0) {
const chunk = Math.min(2, remaining);
if (this.updatePheromoneField(chunk)) pheromoneChanged = true;
remaining -= chunk;
}
return pheromoneChanged;
}
regenerateResources(scale = 1) {
2026-05-13 20:21:58 +09:00
const w = this.world;
for (let i = 0; i < w.count; i++) {
w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15));
2026-05-14 00:21:24 +09:00
}
}
2026-09-16 20:53:17 +09:00
updatePheromoneField(scale = 1) {
const w = this.world;
const active = this.activePheromoneTiles;
if (!active?.size) return false;
const decay = Math.pow(SimConfig.route.pheromoneDecay, scale);
const diffusion = SimConfig.route.pheromoneDiffusion * scale;
const maxPheromone = SimConfig.route.maxPheromone;
const touched = this.pheromoneTouchedScratch;
touched.length = 0;
const marker = this.nextVisitMarker();
const starts = w.landNeighborStarts;
const neighbors = w.landNeighbors;
const markTouched = tile => {
if (this.visitStamp[tile] === marker) return;
this.visitStamp[tile] = marker;
touched.push(tile);
};
for (const tile of active) {
if (tile < 0 || tile >= w.count) continue;
markTouched(tile);
2026-05-14 00:21:24 +09:00
if (w.terrain[tile] === Terrain.WATER) continue;
2026-09-16 20:53:17 +09:00
for (let n = starts[tile]; n < starts[tile + 1]; n++) markTouched(neighbors[n]);
}
const decayed = this.scratchFloatC;
for (const tile of touched) decayed[tile] = w.pheromone[tile] * decay;
const nextActive = this.nextPheromoneTiles;
nextActive.clear();
for (const tile of touched) {
let value;
if (w.terrain[tile] === Terrain.WATER) {
value = Math.min(maxPheromone, decayed[tile] * 0.85);
} else {
let neighborSum = 0;
const begin = starts[tile];
const end = starts[tile + 1];
for (let n = begin; n < end; n++) neighborSum += decayed[neighbors[n]];
const neighborCount = end - begin;
const neighborAverage = neighborCount ? neighborSum / neighborCount : decayed[tile];
value = Math.min(maxPheromone, lerp(decayed[tile], neighborAverage, diffusion));
2026-05-14 00:21:24 +09:00
}
2026-09-16 20:53:17 +09:00
if (value < 0.0005) value = 0;
w.pheromone[tile] = value;
if (value > 0) nextActive.add(tile);
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
this.activePheromoneTiles = nextActive;
this.nextPheromoneTiles = active;
return touched.length > 0;
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
2026-05-13 20:21:58 +09:00
updateCities() {
const w = this.world;
w.city.fill(-1);
w.farmland.fill(0);
w.cityPull.fill(0);
const candidates = new Map();
2026-09-17 22:32:25 +09:00
// Keep the previous valid capacity while rebuilding farmland/city influence.
// Recomputing here would scan the whole map only to discard both bonuses; the
// refreshed capacity is calculated once after the rebuilt fields are ready.
2026-09-16 13:51:43 +09:00
const activeTiles = this.activePopulationSnapshot();
for (const tile of activeTiles) {
2026-05-17 21:08:03 +09:00
if (w.terrain[tile] === Terrain.WATER) continue;
const population = w.population[tile] || 0;
const settled = w.settledPopulation[tile] || 0;
if (settled < 5 && population < 9) continue;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
2026-09-16 20:53:17 +09:00
const foundingSpacing = SimConfig.city.naturalFoundingMinDistance ?? 6;
if (this.getCitiesNear(x, y, Math.max(1, foundingSpacing - 1)).length) continue;
2026-05-17 21:08:03 +09:00
const cx = clamp(Math.round(x / 4) * 4, 0, w.size - 1);
const cy = clamp(Math.round(y / 4) * 4, 0, w.size - 1);
2026-05-13 20:21:58 +09:00
const i = w.idx(cx, cy);
let group = candidates.get(i);
if (!group) {
2026-05-17 21:08:03 +09:00
group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map(), sourceTiles: [] };
2026-05-13 20:21:58 +09:00
candidates.set(i, group);
}
2026-05-17 21:08:03 +09:00
const localWeight = 0.65 + w.fertility[tile] * 0.35 + w.cityPull[tile] * 0.25 + (w.tradeRoute[tile] ? 0.2 : 0);
group.count += population * localWeight;
group.resources += Math.max(0, w.resource[tile]) * Math.max(0.25, population * 0.06);
group.sedentary += settled;
group.farming += w.farmland[tile] * settled;
group.metallurgy += w.mineral[tile] * population;
group.techCount += Math.max(1, population);
group.sourceTiles.push(tile);
const mix = this.tileEthnicMix.get(tile);
if (mix) {
for (const [id, count] of mix) group.ethnicities.set(id, (group.ethnicities.get(id) || 0) + count);
}
2026-05-13 20:21:58 +09:00
}
let foundedThisTick = 0;
2026-09-16 20:53:17 +09:00
const foundingStartYears = SimConfig.city.naturalFoundingStartYears ?? 160;
const foundingIntervalYears = Math.max(1, SimConfig.city.naturalFoundingIntervalYears ?? 5);
const canFoundCities = this.year >= years(foundingStartYears) && this.year % years(foundingIntervalYears) === 0;
const foundingLimit = canFoundCities ? this.naturalCityFoundingLimit() : 0;
const scoredCandidates = [...candidates].map(([tile, group]) => ({
tile,
group,
score: this.cityFoundingScore(tile, group)
}));
const bestFoundingScore = scoredCandidates.reduce((best, entry) => Math.max(best, entry.score), -Infinity);
// Near-equivalent sites should not always resolve to the exact same maximum.
// Keep strong suitability preference, but randomize within the broad top band.
for (const entry of scoredCandidates) {
const nearBest = entry.score >= bestFoundingScore - 4.0;
entry.priority = nearBest ? bestFoundingScore + this.rng.range(0, 4.0) : entry.score;
}
scoredCandidates.sort((a, b) => b.priority - a.priority);
const candidateEntries = scoredCandidates.map(entry => [entry.tile, entry.group]);
2026-05-13 20:21:58 +09:00
for (const [i, group] of candidateEntries) {
2026-09-16 13:51:43 +09:00
if (group.count < 24) continue;
2026-05-17 21:08:03 +09:00
const avgSedentary = clamp(group.sedentary / Math.max(1, group.count), 0, 1);
2026-09-16 20:53:17 +09:00
const foundingSpacing = SimConfig.city.naturalFoundingMinDistance ?? 6;
let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), foundingSpacing)[0] || null;
2026-05-13 20:21:58 +09:00
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) {
2026-05-17 21:08:03 +09:00
const foundingScore = this.cityFoundingScore(i, group);
2026-09-16 13:51:43 +09:00
const foundingChance = clamp((foundingScore - 12) * 0.030, 0.01, 0.38);
if (avgSedentary < 0.38 || foundingScore < 12 || this.rng.next() > foundingChance) continue;
2026-05-13 20:21:58 +09:00
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
this.cities.push(city);
this.cityById.set(city.id, city);
const bucketKey = this.cityBucketKey(city.x, city.y);
if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
this.cityBuckets.get(bucketKey).push(city);
2026-09-16 13:51:43 +09:00
this.assignNewCityToTerritoryOwner(city, i);
2026-05-13 20:21:58 +09:00
foundedThisTick++;
}
if (city) {
city.activeVisitors += group.count;
city.storedResources += group.resources * 0.08;
city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02;
city.knowledge ??= { farming: 0, metallurgy: 0 };
city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount));
city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount));
const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1);
for (const [id, count] of group.ethnicities) {
2026-09-16 20:53:17 +09:00
const urbanCount = this.weightedUrbanContribution(count * 0.2 * SimConfig.city.populationScale / SimConfig.population.populationScale, urbanWeight);
2026-05-13 20:21:58 +09:00
if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount);
}
city.strength = city.strength * 0.96 + group.count * 0.05;
}
}
this.absorbUrbanPopulation();
2026-09-16 13:51:43 +09:00
this.rebuildIndexes();
2026-05-13 20:21:58 +09:00
this.processCityEconomies();
2026-09-16 20:53:17 +09:00
const removedCities = [];
2026-05-13 20:21:58 +09:00
this.cities = this.cities.filter(c => {
c.age++;
c.strength *= 0.992;
const foodPerCapita = c.storedResources / Math.max(1, c.population);
if (c.age > 20) {
if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035;
else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015;
if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018;
}
2026-09-16 20:53:17 +09:00
if (c.population < (SimConfig.city.abandonmentPopulation ?? 200) || c.strength <= 0.06) {
removedCities.push(c);
return false;
}
2026-05-13 20:21:58 +09:00
const radius = c.agriculturalRadius;
2026-09-16 13:51:43 +09:00
for (const offset of this.getRadiusOffsets(radius)) {
const dx = offset.dx;
const dy = offset.dy;
2026-05-13 20:21:58 +09:00
const x = clamp(c.x + dx, 0, w.size - 1);
const y = clamp(c.y + dy, 0, w.size - 1);
2026-09-16 13:51:43 +09:00
const d = offset.distance;
2026-05-13 20:21:58 +09:00
const tile = w.idx(x, y);
if (w.terrain[tile] !== Terrain.WATER) {
w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1));
w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1));
}
}
2026-09-16 20:53:17 +09:00
w.city[w.idx(c.x, c.y)] = c.id;
2026-05-13 20:21:58 +09:00
return true;
});
2026-09-16 20:53:17 +09:00
for (const city of removedCities) {
const oldPolityId = city.polityId;
if (oldPolityId !== null) this.removeCityFromPolity(city);
const tile = w.idx(city.x, city.y);
if (w.city[tile] === city.id) w.city[tile] = -1;
if (oldPolityId !== null) this.releaseCityTerritory(city, oldPolityId, 5, "cityAbandoned");
}
2026-05-13 20:21:58 +09:00
this.rebuildIndexes();
2026-05-17 21:08:03 +09:00
this.updatePopulationCapacity();
2026-09-16 20:53:17 +09:00
if (removedCities.length) this.pruneOrphanTradeRoutes();
}
naturalCityFoundingLimit() {
const referenceArea = Math.max(1, SimConfig.city.naturalFoundingReferenceArea ?? (160 * 160));
const maxPerCycle = Math.max(1, SimConfig.city.naturalFoundingMaxPerCycle ?? 8);
return clamp(Math.ceil(this.world.count / referenceArea), 1, maxPerCycle);
2026-05-17 21:08:03 +09:00
}
cityFoundingScore(tile, group) {
const w = this.world;
const settledShare = group.count > 0 ? group.sedentary / group.count : 0;
const pressure = w.populationPressure[tile] || 0;
const route = w.tradeRoute[tile] ? 1.4 : clamp(w.pheromone[tile] / 10, 0, 1.2);
2026-09-16 20:53:17 +09:00
const spacing = SimConfig.city.naturalFoundingMinDistance ?? 6;
const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), SimConfig.city.foundingCompetitionRadius ?? spacing + 3).length * 11;
2026-05-17 21:08:03 +09:00
return group.count * (0.45 + settledShare) +
pressure * 2.2 +
w.fertility[tile] * 3.4 +
clamp(w.resource[tile] / 18, 0, 3) +
route +
w.mineral[tile] * 0.9 -
w.move[tile] * 1.1 -
nearbyPenalty;
}
2026-09-16 13:51:43 +09:00
assignNewCityToTerritoryOwner(city, tile, options = {}) {
if (!options.explicit) return false;
const w = this.world;
const ownerId = w.territoryOwner?.[tile] ?? -1;
if (!city || ownerId < 0) return false;
const polity = this.getPolityById(ownerId);
if (!polity) return false;
if (city.polityId != null && city.polityId !== ownerId) return false;
const control = clamp(w.control?.[tile] || 0, 0, 1);
const contested = !!w.contested?.[tile];
if (contested && control < 0.35) return false;
const cityEthnicity = this.dominantCityEthnicity(city);
const nearest = this.nearestPolityCityToTile(polity, tile);
const core = nearest || this.getCityById(polity.centerCityId);
const coreEthnicity = this.dominantCityEthnicity(core);
const sameEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity === coreEthnicity;
const differentEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity !== coreEthnicity;
const distance = nearest ? Math.abs(nearest.x - city.x) + Math.abs(nearest.y - city.y) : 24;
const loyalty = 0.38 + control * 0.34 - (contested ? 0.18 : 0) + (sameEthnicity ? 0.10 : 0) - (differentEthnicity ? 0.10 : 0) - clamp((distance - 18) * 0.004, 0, 0.10);
this.addCityToPolity(city, polity, clamp(loyalty, 0.18, 0.82));
return true;
}
nearestPolityCityToTile(polity, tile) {
const w = this.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let best = null;
let bestDistance = Infinity;
for (const city of this.getPolityCities(polity)) {
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
if (distance < bestDistance) {
best = city;
bestDistance = distance;
}
}
return best;
}
2026-05-17 21:08:03 +09:00
drawPopulationFromTilesForCity(city, group, amount) {
const w = this.world;
let remaining = Math.max(0, amount);
for (const tile of group?.sourceTiles || [w.idx(city.x, city.y)]) {
if (remaining <= 0) break;
const available = Math.max(0, w.settledPopulation[tile] || w.population[tile] || 0);
if (available <= 0) continue;
const take = Math.min(available, remaining);
this.removeEthnicPopulationProportionally(tile, take);
remaining -= take;
}
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
createCity(x, y, seedGroup = null, options = {}) {
const scale = SimConfig.city.populationScale;
const seedPopulation = Math.max(1, Math.round(options.initialPopulation ?? SimConfig.city.initialPopulation ?? 400));
2026-05-13 20:21:58 +09:00
const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5;
const composition = new Map();
if (seedGroup) {
const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1);
for (const [id, count] of seedGroup.ethnicities) {
2026-09-16 20:53:17 +09:00
const urbanCount = this.weightedUrbanContribution(count * 0.42 * scale / SimConfig.population.populationScale, urbanWeight);
2026-05-13 20:21:58 +09:00
if (urbanCount > 0) composition.set(id, urbanCount);
}
}
2026-05-17 21:08:03 +09:00
const city = {
2026-05-13 20:21:58 +09:00
id: this.nextCity++,
x,
y,
population: seedPopulation,
2026-09-16 20:53:17 +09:00
storedResources: options.initialResources ?? (34 + (seedGroup?.resources || 0) * 0.55) * scale,
2026-05-13 20:21:58 +09:00
ethnicityComposition: composition,
pheromoneOutput: 0,
agriculturalRadius: 2,
tradeLinks: new Set(),
activeVisitors: 0,
age: 0,
2026-05-16 13:48:08 +09:00
peakPopulation: seedPopulation,
2026-05-13 20:21:58 +09:00
strength: 3,
sedentaryCulture: seedSedentary,
knowledge: { farming: 0, metallurgy: 0 },
supplyStress: 0,
polityId: null,
loyalty: 0.5,
receivedAid: false,
tradeValue: 0,
2026-09-16 20:53:17 +09:00
tradeReach: 0,
rebellionProtectedUntil: 0
2026-05-13 20:21:58 +09:00
};
2026-05-17 21:08:03 +09:00
if (seedGroup) this.drawPopulationFromTilesForCity(city, seedGroup, seedPopulation);
this.projectCityPopulationToTiles(city);
this.syncTilePopulationCulture();
return city;
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
cityCarryingCapacity(city) {
const w = this.world;
if (!city) return 1;
const radius = Math.max(2, city.agriculturalRadius || 2);
let farmlandSum = 0;
let fertilitySum = 0;
let resourceSum = 0;
let weightSum = 0;
for (const offset of this.getRadiusOffsets(radius)) {
const dx = offset.dx;
const dy = offset.dy;
const distance = offset.distance;
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const weight = (radius - distance + 1) / (radius + 1);
farmlandSum += (w.farmland[tile] || 0) * weight;
fertilitySum += (w.fertility[tile] || 0) * weight;
resourceSum += clamp((w.resource[tile] || 0) / 35, 0, 1) * weight;
weightSum += weight;
}
const hinterlandFertility = weightSum > 0 ? fertilitySum / weightSum : 0;
const resourceSupport = weightSum > 0 ? resourceSum / weightSum : 0;
let capacity =
35 +
hinterlandFertility * 120 +
resourceSupport * 55 +
farmlandSum * 18 +
(city.tradeValue || 0) * 45 +
(city.knowledge?.farming || 0) * 90 +
Math.sqrt(Math.max(0, city.storedResources || 0)) * 3;
2026-09-16 20:53:17 +09:00
return Math.max(20, capacity) * SimConfig.city.populationScale;
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
absorbUrbanPopulation() {
// Population is already absorbed into cities when field-founded settlements draw
// from nearby tile population. This hook remains for the city update pipeline.
2026-05-13 20:21:58 +09:00
}
weightedUrbanContribution(amount, weight) {
const value = amount * weight;
const whole = Math.floor(value);
return whole + (this.rng.next() < value - whole ? 1 : 0);
}
2026-09-16 20:53:17 +09:00
cityRecoveryGrowthBonus(city) {
if (!city || city.population <= 0) return 0;
const peak = Math.max(city.population, city.peakPopulation || city.population);
if (peak <= 0) return 0;
const recoveryGap = clamp((peak - city.population) / peak, 0, 0.75);
return recoveryGap * (SimConfig.city.postShockRecoveryGrowth ?? 0.045);
}
2026-05-13 20:21:58 +09:00
processCityEconomies() {
const w = this.world;
for (const city of this.cities) {
2026-09-16 20:53:17 +09:00
city.peakPopulation = Math.max(city.peakPopulation || 0, city.population || 0);
const populationScale = SimConfig.city.populationScale;
city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population / populationScale) / 4.5), 2, 14);
2026-05-13 20:21:58 +09:00
city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
city.knowledge ??= { farming: 0, metallurgy: 0 };
let harvested = 0;
const radius = city.agriculturalRadius;
2026-09-16 13:51:43 +09:00
for (const offset of this.getRadiusOffsets(radius)) {
const dx = offset.dx;
const dy = offset.dy;
const distance = offset.distance;
2026-05-13 20:21:58 +09:00
const x = clamp(city.x + dx, 0, w.size - 1);
const y = clamp(city.y + dy, 0, w.size - 1);
const i = w.idx(x, y);
if (w.terrain[i] === Terrain.WATER) continue;
2026-09-16 13:51:43 +09:00
const pull = (radius - distance + 1) / (radius + 1);
2026-05-13 20:21:58 +09:00
const farmingYield = 1 + city.knowledge.farming * 0.85;
const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
2026-09-16 20:53:17 +09:00
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull * SimConfig.city.foodProductionScale);
2026-05-13 20:21:58 +09:00
w.resource[i] -= extraction;
w.farmland[i] = Math.max(w.farmland[i], pull);
2026-05-14 00:21:24 +09:00
this.addPheromone(i, city.pheromoneOutput * pull * 0.09);
2026-05-13 20:21:58 +09:00
harvested += extraction;
}
city.storedResources += harvested;
const trade = this.cityTradeProfile(city);
2026-09-16 20:53:17 +09:00
const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population / populationScale)) * 0.018) * populationScale;
2026-05-13 20:21:58 +09:00
city.storedResources += tradeIncome;
2026-09-16 20:53:17 +09:00
const supportRatio = (city.activeVisitors / SimConfig.population.populationScale) / Math.max(1, city.population / populationScale);
2026-05-13 20:21:58 +09:00
const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
const tradeRelief = clamp(trade.value * 0.09, 0, 0.32);
const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance;
city.storedResources -= upkeep;
const foodPerCapita = city.storedResources / Math.max(1, city.population);
city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8);
this.innovateCityKnowledge(city, harvested);
if (trade.value > 0) {
city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1);
city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1);
city.strength += clamp(trade.value * 0.018, 0, 0.08);
city.tradeValue = trade.value;
city.tradeReach = trade.reach;
} else {
city.tradeValue = 0;
city.tradeReach = 0;
}
2026-09-16 13:51:43 +09:00
const capacity = this.cityCarryingCapacity(city);
2026-09-16 20:53:17 +09:00
const recoveryBonus = this.cityRecoveryGrowthBonus(city);
const recoveryMode = recoveryBonus > 0.002;
const growthReserveRatio = recoveryMode ? 0.06 : 0.11;
const growthStressLimit = recoveryMode ? 1.20 : 0.90;
if (city.storedResources > city.population * growthReserveRatio && city.population > 0 && city.population < capacity && city.supplyStress < growthStressLimit) {
2026-05-13 20:21:58 +09:00
const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8);
2026-09-16 13:51:43 +09:00
const birthRoom = Math.max(0, Math.floor(capacity - city.population));
2026-09-16 20:53:17 +09:00
const births = Math.min(birthRoom, Math.max(1, Math.floor(city.population * (
0.012 +
prosperity * 0.012 +
clamp(trade.value, 0, 1.8) * 0.0015 +
recoveryBonus
))));
2026-05-13 20:21:58 +09:00
city.population += births;
city.storedResources -= births * 0.55;
2026-05-14 14:38:34 +09:00
addBirthsToComposition(city.ethnicityComposition, births, this.rng);
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
if (city.population > capacity && city.supplyStress > 0.45) {
const overLimit = city.population - capacity;
2026-09-16 20:53:17 +09:00
const overCapacityLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.overCapacityAttrition ?? 0.020))));
city.population -= overCapacityLoss;
removeFromComposition(city.ethnicityComposition, overCapacityLoss);
city.strength -= Math.min(0.08, overCapacityLoss / Math.max(1, city.population + overCapacityLoss) * 0.18);
2026-05-16 13:48:08 +09:00
}
2026-05-13 20:21:58 +09:00
if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) {
const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010)));
city.population -= attrition;
removeFromComposition(city.ethnicityComposition, attrition);
city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012;
}
if (city.storedResources < 0) {
const deficit = Math.abs(city.storedResources);
const dominant = dominantComposition(city.ethnicityComposition);
const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035));
city.population -= loss;
city.storedResources = 0;
removeFromComposition(city.ethnicityComposition, loss);
city.strength -= Math.min(0.4, 0.03 + deficit * 0.01);
2026-09-16 13:51:43 +09:00
if (loss > 0) {
2026-05-13 20:21:58 +09:00
this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant);
}
}
city.population = Math.max(0, Math.floor(city.population));
city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4));
}
}
cityTradeProfile(city) {
if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 };
let value = 0;
let reach = 0;
for (const link of this.tradeLinks) {
if (link.from !== city.id && link.to !== city.id) continue;
const other = this.getCityById(link.from === city.id ? link.to : link.from);
if (!other) continue;
const distance = this.distanceBetweenCities(city, other);
const distanceFactor = clamp((distance - 14) / 30, 0, 1);
if (distanceFactor <= 0) continue;
const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4);
const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6);
value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale;
reach = Math.max(reach, distance);
}
return {
value: clamp(value, 0, 3.2),
reach
};
}
innovateCityKnowledge(city, harvested) {
city.knowledge ??= { farming: 0, metallurgy: 0 };
const scale = SimConfig.technology.cityInnovation;
const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6);
const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5);
city.knowledge.farming = clamp(
city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015,
0,
1
);
city.knowledge.metallurgy = clamp(
city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35,
0,
1
);
if (city.supplyStress > 0.8) {
city.knowledge.farming *= 0.998;
city.knowledge.metallurgy *= 0.997;
}
}
spawnUrbanRefugees(city, count, ethnicity = null) {
2026-09-16 13:51:43 +09:00
const tile = this.world.idx(city.x, city.y);
const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || this.world.dominantEthnicity[tile];
if (dominant != null && dominant >= 0) this.seedEthnicPopulationPatch(tile, dominant, this.ethnicities.get(dominant)?.averageTraits || this.randomTraits(), count, 2);
2026-05-13 20:21:58 +09:00
}
findCityNear(x, y, radius) {
let best = null;
let bestDistance = Infinity;
for (const c of this.getCitiesNear(x, y, radius)) {
const d = Math.abs(c.x - x) + Math.abs(c.y - y);
if (d <= radius && d < bestDistance) {
best = c;
bestDistance = d;
}
}
return best;
}
2026-09-17 22:32:25 +09:00
applyTerritoryExitPopulationShock(tile, rate = SimConfig.population?.territoryExitPopulationLossRate ?? 0.12) {
const w = this.world;
if (!w?.population || !w?.terrain || tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0;
const population = w.population[tile] || 0;
if (population <= 0 || rate <= 0) return 0;
return this.removeEthnicPopulationProportionally(tile, population * clamp(rate, 0, 0.95));
}
finishTrackedTerritoryExits() {
const w = this.world;
let removed = 0;
for (const tile of this.territoryExitCandidates) {
if (w.territoryOwner[tile] < 0) removed += this.applyTerritoryExitPopulationShock(tile);
}
this.territoryExitCandidates.length = 0;
return removed;
}
2026-05-13 20:21:58 +09:00
cityInfluence(city) {
if (!city || city.population <= 0 || city.strength <= 0) return 0;
const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35);
return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor;
}
2026-09-16 13:51:43 +09:00
setTileOwner(tile, polityId, options = {}) {
const w = this.world;
2026-09-16 20:53:17 +09:00
if (tile == null || tile < 0 || tile >= w.count || (w.terrain[tile] === Terrain.WATER && (polityId ?? -1) >= 0)) return false;
2026-09-17 22:32:25 +09:00
const previousOwner = w.territoryOwner[tile];
2026-09-16 13:51:43 +09:00
const owner = polityId ?? -1;
if (owner >= 0 && !this.getPolityById(owner)) return false;
w.territoryOwner[tile] = owner;
w.polity[tile] = owner;
2026-09-17 22:32:25 +09:00
if (previousOwner >= 0 && owner < 0 && !this.suppressTerritoryExitShock && options.populationShock !== false) {
this.applyTerritoryExitPopulationShock(tile);
}
2026-09-16 13:51:43 +09:00
if (owner < 0) {
w.control[tile] = 0;
w.claim[tile] = 0;
w.contested[tile] = 0;
const stored = this.territorialClaims.get(tile);
if (stored) this.territorialClaims.delete(tile);
return true;
}
if (options.control != null) w.control[tile] = Math.max(w.control[tile] || 0, options.control);
if (options.claim != null) w.claim[tile] = Math.max(w.claim[tile] || 0, options.claim);
if (options.contested != null) w.contested[tile] = options.contested ? 1 : 0;
return true;
}
syncVisibleTerritoryOwners() {
const w = this.world;
2026-09-16 20:53:17 +09:00
if (w.polity !== w.territoryOwner) w.polity.set(w.territoryOwner);
2026-09-16 13:51:43 +09:00
}
validPolityIds() {
return new Set(this.polities.map(polity => polity.id));
}
2026-09-16 20:53:17 +09:00
// Territory follows a weighted nearest-city model. Distance is primary, while
// city/state power, loyalty, trade, terrain and culture shift the boundary.
2026-05-17 21:08:03 +09:00
clearTerritories() {
const w = this.world;
2026-09-16 13:51:43 +09:00
this.syncVisibleTerritoryOwners();
2026-05-17 21:08:03 +09:00
w.control.fill(0);
w.claim.fill(0);
w.contested.fill(0);
}
2026-09-16 13:51:43 +09:00
recomputeTerritories(options = {}) {
2026-05-17 21:08:03 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
const allowOwnershipChanges = !!options.allowOwnershipChanges;
2026-09-17 22:32:25 +09:00
const trackTerritoryExits = allowOwnershipChanges && !this.suppressTerritoryExitShock;
if (trackTerritoryExits) {
this.territoryExitCandidates.length = 0;
for (const tile of this.activePopulationTiles) {
if (w.territoryOwner[tile] >= 0) this.territoryExitCandidates.push(tile);
}
this.suppressTerritoryExitShock = true;
}
2026-05-17 21:08:03 +09:00
this.clearTerritories();
2026-09-16 20:53:17 +09:00
if (!this.polities.length || !this.cities.length) {
this.enforceTerritoryConnectivity();
2026-09-17 22:32:25 +09:00
if (trackTerritoryExits) {
this.suppressTerritoryExitShock = false;
this.finishTrackedTerritoryExits();
}
2026-09-16 20:53:17 +09:00
return;
}
2026-09-16 13:51:43 +09:00
const best = this.scratchFloatA;
const second = this.scratchFloatB;
2026-09-16 20:53:17 +09:00
const incumbent = this.scratchFloatC;
2026-09-16 13:51:43 +09:00
const owner = this.scratchIntB;
2026-09-16 20:53:17 +09:00
const bestSupport = this.territoryBestSupport;
const incumbentSupport = this.territoryIncumbentSupport;
2026-09-16 13:51:43 +09:00
best.fill(0);
second.fill(0);
2026-09-16 20:53:17 +09:00
incumbent.fill(0);
2026-05-17 21:08:03 +09:00
owner.fill(-1);
2026-09-16 20:53:17 +09:00
bestSupport.fill(0);
incumbentSupport.fill(0);
2026-05-17 21:08:03 +09:00
for (const polity of this.polities) {
const polityCities = this.getPolityCities(polity);
if (!polityCities.length) continue;
const center = this.getCityById(polity.centerCityId) || polityCities[0];
const maxRange = clamp(Math.ceil(this.polityInfluenceRange(polity, false)), 8, 36);
for (const city of polityCities) {
2026-09-16 20:53:17 +09:00
const targetPopulationSupport = clamp(
Math.sqrt(Math.max(0, city.population || 0) / (SimConfig.territory?.fullMaintenanceCityPopulation ?? 2400)),
SimConfig.territory?.minimumPopulationSupport ?? 0.28,
1
);
if (city.territorySupport == null) city.territorySupport = targetPopulationSupport;
else if (city.territorySupportUpdatedAt !== this.year) {
city.territorySupport = lerp(city.territorySupport, targetPopulationSupport, SimConfig.territory?.supportSmoothing ?? 0.18);
}
city.territorySupportUpdatedAt = this.year;
const populationSupport = city.territorySupport;
2026-05-17 21:08:03 +09:00
const cityTile = w.idx(city.x, city.y);
const cityEthnicity = this.dominantCityEthnicity(city);
const capitalDistance = center ? this.effectiveDistance(center, city) : 0;
const capitalFactor = city.id === polity.centerCityId ? 1.28 : clamp(1 - capitalDistance / 96, 0.58, 1);
const loyaltyFactor = 0.62 + clamp(city.loyalty ?? 0.5, 0, 1) * 0.58;
const tradeFactor = 1 + clamp((city.tradeReach || 0) / 60, 0, 0.42) + clamp((city.tradeValue || 0) * 0.10, 0, 0.22);
2026-05-18 17:13:18 +09:00
const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor;
2026-09-16 20:53:17 +09:00
const normalizedInfluence = baseInfluence / Math.sqrt(SimConfig.city.populationScale || 1);
const cityPowerReach = clamp(Math.log1p(normalizedInfluence) * (SimConfig.territory?.cityPowerWeight ?? 1.8), 2, 10);
const capitalReach = city.id === polity.centerCityId ? (SimConfig.territory?.capitalReachBonus ?? 1.5) : 0;
const radiusScale = (SimConfig.territory?.radiusScale ?? 0.5) * populationSupport;
const radius = clamp(
Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45)) * radiusScale),
3,
Math.max(3, Math.ceil(maxRange * radiusScale))
);
for (const { dx, dy } of this.getRadiusOffsets(Math.ceil(radius * Math.SQRT2))) {
const distance = Math.hypot(dx, dy);
if (distance > radius) continue;
2026-05-17 21:08:03 +09:00
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
const isWater = w.terrain[tile] === Terrain.WATER;
if (isWater && tile !== cityTile && !w.tradeRoute[tile] && !this.territoryWaterAnchor(tile)) continue;
2026-09-16 20:53:17 +09:00
if ((w.population[tile] || 0) <= 0 && tile !== cityTile) continue;
2026-05-18 17:13:18 +09:00
const routeBoost = w.tradeRoute[tile] ? 1.28 : 1;
2026-05-17 21:08:03 +09:00
const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1);
2026-09-16 20:53:17 +09:00
const effectiveDistance = (distance + terrainDrag * 1.4) / routeBoost;
2026-05-18 17:13:18 +09:00
const localEthnicity = w.dominantEthnicity[tile];
2026-09-16 20:53:17 +09:00
const ethnicityShift = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? -0.65 : 0.35;
const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 40, 0, 0.7);
const maintainedReach = (SimConfig.territory?.baseCityReach ?? 9) * (0.45 + populationSupport * 0.55);
const score = maintainedReach - effectiveDistance + cityPowerReach * populationSupport + capitalReach + ethnicityShift + populationAnchor;
2026-05-17 21:08:03 +09:00
if (score <= 0.8) continue;
2026-09-16 20:53:17 +09:00
if (w.territoryOwner[tile] === polity.id && score > incumbent[tile]) {
incumbent[tile] = score;
incumbentSupport[tile] = populationSupport;
}
2026-05-17 21:08:03 +09:00
if (score > best[tile]) {
if (owner[tile] !== polity.id) second[tile] = best[tile];
best[tile] = score;
owner[tile] = polity.id;
2026-09-16 20:53:17 +09:00
bestSupport[tile] = populationSupport;
2026-05-17 21:08:03 +09:00
} else if (owner[tile] !== polity.id && score > second[tile]) {
second[tile] = score;
}
}
}
}
2026-09-16 20:53:17 +09:00
const threshold = SimConfig.territory?.minimumInfluence ?? 2.4;
const rebalanceOwnership = !!options.rebalanceOwnership;
const switchMargin = SimConfig.territory?.ownershipSwitchMargin ?? 1.5;
const activeWarPairs = new Set((this.wars || [])
.filter(war => war.ended === null)
.map(war => `${Math.min(war.aPolityId, war.bPolityId)}:${Math.max(war.aPolityId, war.bPolityId)}`));
2026-05-17 21:08:03 +09:00
for (let i = 0; i < w.count; i++) {
2026-09-16 13:51:43 +09:00
const currentOwner = w.territoryOwner[i];
2026-09-16 20:53:17 +09:00
const proposedOwner = owner[i];
const cityHere = w.city[i] >= 0 ? this.getCityById(w.city[i]) : null;
const protectedCityCore = cityHere?.polityId != null && cityHere.polityId !== proposedOwner;
const currentCityCore = cityHere?.polityId === currentOwner;
const atWarAcrossBorder = currentOwner >= 0 && proposedOwner >= 0 && currentOwner !== proposedOwner &&
activeWarPairs.has(`${Math.min(currentOwner, proposedOwner)}:${Math.max(currentOwner, proposedOwner)}`);
const currentSupport = incumbentSupport[i] || 0;
const proposedSupport = bestSupport[i] || 0;
const maintenanceThreshold = threshold + (1 - currentSupport) * (SimConfig.territory?.maintenancePressure ?? 4);
const expansionThreshold = threshold + (1 - proposedSupport) * (SimConfig.territory?.maintenancePressure ?? 4);
if (
allowOwnershipChanges &&
currentOwner >= 0 &&
currentSupport < 1 &&
!currentCityCore &&
incumbent[i] < maintenanceThreshold
) {
this.setTileOwner(i, -1);
this.territorialClaims.delete(i);
continue;
}
const shouldSwitchOwner =
rebalanceOwnership &&
currentOwner >= 0 &&
proposedOwner >= 0 &&
proposedOwner !== currentOwner &&
!protectedCityCore &&
!atWarAcrossBorder &&
best[i] >= expansionThreshold &&
best[i] >= incumbent[i] + switchMargin + (1 - Math.min(currentSupport, proposedSupport)) * (SimConfig.territory?.smallStateSwitchPenalty ?? 2.5);
if (allowOwnershipChanges && (currentOwner < 0 || shouldSwitchOwner) && proposedOwner >= 0 && best[i] >= expansionThreshold) {
this.setTileOwner(i, proposedOwner, {
2026-09-16 13:51:43 +09:00
control: clamp((best[i] - threshold) / 18, 0.12, 1),
claim: clamp(best[i] / 22, 0, 1)
});
2026-09-16 20:53:17 +09:00
if (this.territorialClaims.get(i)?.polityId !== proposedOwner) this.territorialClaims.delete(i);
2026-09-16 13:51:43 +09:00
continue;
}
if (currentOwner < 0) {
2026-09-16 20:53:17 +09:00
if (owner[i] >= 0 && best[i] >= expansionThreshold) {
2026-09-16 13:51:43 +09:00
w.claim[i] = clamp(best[i] / 28, 0, 0.45);
if (second[i] > best[i] * 0.72) w.contested[i] = 1;
}
continue;
}
if (owner[i] === currentOwner && best[i] >= threshold) {
2026-05-17 21:08:03 +09:00
w.claim[i] = clamp(best[i] / 22, 0, 1);
2026-05-18 17:13:18 +09:00
w.control[i] = clamp((best[i] - threshold) / 18, 0.12, 1);
2026-05-17 21:08:03 +09:00
if (second[i] > best[i] * 0.72) w.contested[i] = 1;
2026-09-16 13:51:43 +09:00
} else {
w.claim[i] = Math.max(w.claim[i], 0.18);
w.control[i] = Math.max(w.control[i], 0.08);
if (owner[i] >= 0 && owner[i] !== currentOwner && best[i] >= threshold * 0.8) w.contested[i] = 1;
}
2026-05-17 21:08:03 +09:00
}
this.applyStoredTerritorialClaims();
2026-09-16 13:51:43 +09:00
this.enforceCityTerritoryAnchors(options);
2026-09-16 20:53:17 +09:00
if (allowOwnershipChanges) this.smoothTerritoryBorders(1);
this.pruneUnpopulatedTerritory();
2026-09-16 13:51:43 +09:00
this.enforceTerritoryConnectivity(options);
2026-09-17 22:32:25 +09:00
if (trackTerritoryExits) {
this.suppressTerritoryExitShock = false;
this.finishTrackedTerritoryExits();
}
2026-05-17 21:08:03 +09:00
}
2026-09-16 20:53:17 +09:00
pruneUnpopulatedTerritory() {
const w = this.world;
for (let tile = 0; tile < w.count; tile++) {
if (w.territoryOwner[tile] < 0 || (w.population[tile] || 0) > 0) continue;
const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
if (city && city.population > 0) continue;
this.setTileOwner(tile, -1);
this.territorialClaims.delete(tile);
}
}
smoothTerritoryBorders(passes = 2) {
const w = this.world;
const source = this.scratchIntA;
const protectedTiles = this.scratchUintA;
protectedTiles.fill(0);
for (const city of this.cities) {
const tile = w.idx(city.x, city.y);
protectedTiles[tile] = 1;
for (let n = w.landNeighborStarts[tile]; n < w.landNeighborStarts[tile + 1]; n++) {
protectedTiles[w.landNeighbors[n]] = 1;
}
}
for (let pass = 0; pass < passes; pass++) {
source.set(w.territoryOwner);
let changed = 0;
for (let tile = 0; tile < w.count; tile++) {
if (w.terrain[tile] === Terrain.WATER || protectedTiles[tile] || w.tradeRoute[tile]) continue;
const start = w.landNeighborStarts[tile];
const end = w.landNeighborStarts[tile + 1];
if (end - start < 6) continue; // Preserve coasts and the edge of the world.
const owner = source[tile];
let candidate = -1;
let same = 0;
let neutral = 0;
let foreign = false;
for (let n = start; n < end; n++) {
const neighborOwner = source[w.landNeighbors[n]];
if (neighborOwner < 0) neutral++;
else if (owner >= 0) {
if (neighborOwner === owner) same++;
else foreign = true;
} else if (candidate < 0) { candidate = neighborOwner; same++; }
else if (neighborOwner === candidate) same++;
else foreign = true;
}
const x = tile % w.size;
let cardinal = 0;
const target = owner >= 0 ? owner : candidate;
if (x > 0 && source[tile - 1] === target) cardinal++;
if (x < w.size - 1 && source[tile + 1] === target) cardinal++;
if (tile >= w.size && source[tile - w.size] === target) cardinal++;
if (tile < w.count - w.size && source[tile + w.size] === target) cardinal++;
if (foreign) {
// At a shared border, move only small leaves toward a clear local majority.
// Tiles with two connections to their own country can be bridges: keep them.
if (owner < 0 || cardinal > 1) continue;
let majority = -1;
let votes = 0;
for (let n = start; n < end; n++) {
const id = source[w.landNeighbors[n]];
if (!votes) { majority = id; votes = 1; }
else votes += id === majority ? 1 : -1;
}
if (majority < 0 || majority === owner) continue;
let count = 0;
let touching = false;
for (let n = start; n < end; n++) {
const neighbor = w.landNeighbors[n];
if (source[neighbor] !== majority) continue;
count++;
if (neighbor === tile - w.size || neighbor === tile + w.size ||
(Math.floor(neighbor / w.size) === Math.floor(tile / w.size) && Math.abs(neighbor - tile) === 1)) touching = true;
}
if (count < 5 || !touching) continue;
this.setTileOwner(tile, majority, { control: 0.22, claim: 0.35, contested: false });
this.territorialClaims.set(tile, { polityId: majority, control: 0.22, claim: 0.35, reason: "borderSmoothing", year: this.year });
changed++;
continue;
}
if (owner < 0 && candidate >= 0 && same >= 5 && cardinal >= 2) {
this.setTileOwner(tile, candidate, { control: 0.22, claim: 0.35 });
changed++;
} else if (owner >= 0 && neutral >= 5 && cardinal <= 1) {
// Trim only leaves; keep narrow bridges and corridors connected.
this.setTileOwner(tile, -1);
this.territorialClaims.delete(tile);
changed++;
}
}
if (!changed) break;
}
this.syncVisibleTerritoryOwners();
}
2026-05-17 21:08:03 +09:00
applyStoredTerritorialClaims() {
const w = this.world;
2026-09-16 13:51:43 +09:00
const validPolities = this.validPolityIds();
2026-05-17 21:08:03 +09:00
for (const [tile, claim] of [...this.territorialClaims]) {
if (!validPolities.has(claim.polityId) || w.terrain[tile] === Terrain.WATER) {
this.territorialClaims.delete(tile);
continue;
}
2026-09-16 13:51:43 +09:00
const polity = this.getPolityById(claim.polityId);
const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
if (!polity || (city && city.polityId !== null && city.polityId !== claim.polityId)) {
this.territorialClaims.delete(tile);
continue;
}
if (city && city.polityId === null) {
w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35);
w.control[tile] = Math.max(w.control[tile], Math.min(0.34, claim.control || 0.25));
w.contested[tile] = 1;
continue;
}
2026-05-17 21:08:03 +09:00
w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35);
w.control[tile] = Math.max(w.control[tile], claim.control || 0.25);
if (claim.contested) w.contested[tile] = 1;
}
}
2026-09-16 13:51:43 +09:00
enforceCityTerritoryAnchors(options = {}) {
2026-05-17 21:08:03 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
const allowOwnershipChanges = !!options.allowOwnershipChanges;
for (const city of this.cities) {
if (city.population <= 0) continue;
const tile = w.idx(city.x, city.y);
if (city.polityId === null) {
2026-09-16 20:53:17 +09:00
this.setTileOwner(tile, -1);
this.territorialClaims.delete(tile);
2026-09-16 13:51:43 +09:00
continue;
}
const polity = this.getPolityById(city.polityId);
if (!polity) continue;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const t = w.idx(x, y);
if (w.terrain[t] === Terrain.WATER) continue;
const otherCity = w.city[t] >= 0 ? this.getCityById(w.city[t]) : null;
if (otherCity && otherCity.id !== city.id && otherCity.polityId !== city.polityId) continue;
const core = dx === 0 && dy === 0;
2026-09-16 20:53:17 +09:00
if (core || (allowOwnershipChanges && w.territoryOwner[t] < 0)) this.setTileOwner(t, polity.id);
if (core && this.territorialClaims.get(t)?.polityId !== polity.id) this.territorialClaims.delete(t);
2026-09-16 13:51:43 +09:00
const ownedByCityPolity = w.territoryOwner[t] === polity.id;
2026-09-16 20:53:17 +09:00
if (!ownedByCityPolity) {
if (w.territoryOwner[t] >= 0) w.contested[t] = 1;
continue;
}
2026-09-16 13:51:43 +09:00
w.control[t] = Math.max(w.control[t], core ? 0.55 : 0.35);
w.claim[t] = Math.max(w.claim[t], core ? 0.62 : 0.40);
if (core && (ownedByCityPolity || allowOwnershipChanges)) w.contested[t] = 0;
}
}
}
}
enforceTerritoryConnectivity(options = {}) {
const w = this.world;
2026-09-16 20:53:17 +09:00
this.enforceCityTerritoryAnchors();
2026-09-16 13:51:43 +09:00
let removed = 0;
const reachable = this.visitStamp;
const marker = this.nextVisitMarker();
2026-09-16 20:53:17 +09:00
const queue = this.scratchIntA;
let tail = 0;
const validPolities = this.validPolityIds();
for (const city of this.cities) {
if (city.population <= 0 || city.polityId == null || !validPolities.has(city.polityId)) continue;
const seed = w.idx(city.x, city.y);
if (w.terrain[seed] === Terrain.WATER || w.territoryOwner[seed] !== city.polityId || reachable[seed] === marker) continue;
2026-09-16 13:51:43 +09:00
reachable[seed] = marker;
2026-09-16 20:53:17 +09:00
queue[tail++] = seed;
2026-09-16 13:51:43 +09:00
}
2026-09-16 20:53:17 +09:00
// All countries share one traversal: each tile is visited at most once.
for (let q = 0; q < tail; q++) {
2026-09-16 13:51:43 +09:00
const tile = queue[q];
2026-09-16 20:53:17 +09:00
const owner = w.territoryOwner[tile];
2026-09-16 13:51:43 +09:00
this.forCardinalNeighbors(tile, n => {
2026-09-16 20:53:17 +09:00
if (reachable[n] === marker || w.territoryOwner[n] !== owner || w.terrain[n] === Terrain.WATER) return;
2026-09-16 13:51:43 +09:00
reachable[n] = marker;
2026-09-16 20:53:17 +09:00
queue[tail++] = n;
2026-09-16 13:51:43 +09:00
});
}
for (let i = 0; i < w.count; i++) {
2026-09-16 20:53:17 +09:00
if (w.territoryOwner[i] < 0 || reachable[i] === marker) continue;
2026-09-16 13:51:43 +09:00
this.setTileOwner(i, -1);
2026-09-16 20:53:17 +09:00
this.territorialClaims.delete(i);
2026-09-16 13:51:43 +09:00
removed++;
}
this.territoryDebug.disconnectedTerritoryRemoved += removed;
2026-09-16 20:53:17 +09:00
this.syncVisibleTerritoryOwners();
2026-09-16 13:51:43 +09:00
return removed;
}
reconcileCityTerritoryOwnership() {
const w = this.world;
for (const city of this.cities) {
if (city.population <= 0) continue;
const tile = w.idx(city.x, city.y);
const ownerId = w.territoryOwner[tile];
if (city.polityId !== null) {
if (ownerId !== city.polityId) {
w.contested[tile] = this.activeEnemyCampaignNearCity(city, 4) ? 1 : w.contested[tile];
this.territoryDebug.cityTerritoryMismatchesFixed++;
}
continue;
}
const territoryOwner = ownerId >= 0 ? this.getPolityById(ownerId) : null;
if (territoryOwner) w.contested[tile] = 1;
}
this.enforceCityTerritoryAnchors({ allowOwnershipChanges: false });
}
activeEnemyCampaignNearCity(city, radius) {
const tile = this.world.idx(city.x, city.y);
const x = city.x;
const y = city.y;
for (const campaign of this.campaigns || []) {
if (campaign.status !== "active" || campaign.targetTile == null) continue;
if (campaign.attackerPolityId === city.polityId) continue;
const cx = campaign.targetTile % this.world.size;
const cy = Math.floor(campaign.targetTile / this.world.size);
if (Math.abs(cx - x) + Math.abs(cy - y) <= radius || campaign.targetTile === tile) return true;
}
return false;
}
territoryWaterAnchor(tile) {
const w = this.world;
let anchored = false;
this.forCardinalNeighbors(tile, n => {
if (w.city[n] < 0 && w.tradeRoute[n] <= 0) return;
anchored = true;
2026-05-17 21:08:03 +09:00
return false;
2026-09-16 13:51:43 +09:00
});
return anchored;
2026-05-17 21:08:03 +09:00
}
isBorderTile(tile) {
const w = this.world;
const polityId = w.polity[tile];
if (polityId < 0) return false;
2026-09-16 13:51:43 +09:00
let border = false;
this.forCardinalNeighbors(tile, n => {
if (w.polity[n] === polityId) return;
border = true;
2026-05-17 21:08:03 +09:00
return false;
2026-09-16 13:51:43 +09:00
});
return border;
2026-05-17 21:08:03 +09:00
}
campaignPressureOnTerritories() {
const w = this.world;
const cfg = SimConfig.campaign || {};
2026-05-18 17:13:18 +09:00
const radius = cfg.claimRadius ?? 8;
2026-05-17 21:08:03 +09:00
for (const campaign of this.campaigns || []) {
if (campaign.status !== "active" || campaign.targetTile == null) continue;
const attacker = this.getPolityById(campaign.attackerPolityId);
if (!attacker) continue;
const cx = campaign.targetTile % w.size;
const cy = Math.floor(campaign.targetTile / w.size);
2026-05-18 17:13:18 +09:00
const pressure = clamp(0.10 + campaign.progress * 0.28, 0.08, 0.42);
2026-09-16 13:51:43 +09:00
for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) {
2026-05-17 21:08:03 +09:00
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const local = pressure * (1 - distance / (radius + 1));
2026-09-16 13:51:43 +09:00
const connected = w.territoryOwner[tile] === attacker.id || this.expansionConnectionDistance(attacker, tile, 5) <= 5;
const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
const ownedCityCore = city?.polityId === attacker.id;
if ((w.territoryOwner[tile] < 0 && connected) || w.territoryOwner[tile] === attacker.id || ownedCityCore) {
2026-05-17 21:08:03 +09:00
w.claim[tile] = Math.max(w.claim[tile], local);
w.control[tile] = Math.max(w.control[tile], local * 0.65);
} else {
w.claim[tile] = Math.max(w.claim[tile], local);
w.contested[tile] = 1;
}
}
}
}
2026-05-13 20:21:58 +09:00
getCityById(id) {
const cached = this.cityById?.get(id);
if (cached) return cached;
const city = this.cities.find(c => c.id === id) || null;
2026-09-16 20:53:17 +09:00
if (city) {
this.cityById ??= new Map();
this.cityById.set(id, city);
}
2026-05-13 20:21:58 +09:00
return city;
}
getPolityById(id) {
const cached = this.polityById?.get(id);
if (cached) return cached;
const polity = this.polities.find(p => p.id === id) || null;
2026-09-16 20:53:17 +09:00
if (polity) {
this.polityById ??= new Map();
this.polityById.set(id, polity);
}
2026-05-13 20:21:58 +09:00
return polity;
}
getPolityCities(polity) {
2026-09-17 22:32:25 +09:00
if (!polity) return [];
this.polityCitiesCache ??= new Map();
const cached = this.polityCitiesCache.get(polity.id);
if (cached) return cached;
2026-05-13 20:21:58 +09:00
const living = [];
for (const id of [...polity.cityIds]) {
const city = this.getCityById(id);
2026-09-17 22:32:25 +09:00
if (!city || city.population <= 0) polity.cityIds.delete(id);
else living.push(city);
2026-05-13 20:21:58 +09:00
}
2026-09-17 22:32:25 +09:00
this.polityCitiesCache.set(polity.id, living);
2026-05-13 20:21:58 +09:00
return living;
}
dominantCityEthnicity(city) {
if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null;
return dominantComposition(city.ethnicityComposition) || null;
}
sameDominantEthnicity(cityA, cityB) {
const a = this.dominantCityEthnicity(cityA);
const b = this.dominantCityEthnicity(cityB);
return a !== null && b !== null && a === b;
}
distanceBetweenCities(cityA, cityB) {
return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y);
}
hasDirectTradeConnection(cityA, cityB) {
2026-05-14 14:38:34 +09:00
if (!cityA || !cityB) return false;
if (cityA.tradeLinks?.has(cityB.id) || cityB.tradeLinks?.has(cityA.id)) return true;
return (this.tradeLinks || []).some(link => {
const endpoints = this.tradeLinkEndpointIds(link);
return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id);
});
}
tradeLinkEndpointId(value) {
return typeof value === "object" ? value?.id ?? null : value ?? null;
}
tradeLinkEndpointIds(link) {
const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityAId ?? link.aId ?? link.sourceId ?? link.source ?? link.a ?? link.cityA);
const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB);
return from != null && to != null ? [from, to] : null;
}
2026-05-17 21:08:03 +09:00
polityTradeNeighbors(city, polityId) {
if (!city || polityId == null) return [];
const neighbors = [];
for (const link of this.tradeLinks || []) {
const endpoints = this.tradeLinkEndpointIds(link);
if (!endpoints) continue;
const [from, to] = endpoints;
const otherId = from === city.id ? to : to === city.id ? from : null;
if (otherId == null) continue;
const other = this.getCityById(otherId);
if (other?.polityId === polityId) neighbors.push(other);
}
return neighbors;
}
tradeAccessToCenter(city, center, polityId) {
if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] };
if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] };
if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] };
const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8;
const visited = new Set([city.id]);
const queue = [{ city, hops: 0, transitCities: [] }];
while (queue.length) {
const current = queue.shift();
if (current.hops >= maxDepth) continue;
for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) {
if (visited.has(neighbor.id)) continue;
const hops = current.hops + 1;
if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities };
visited.add(neighbor.id);
queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] });
}
}
return { reachable: false, hops: Infinity, transitCities: [] };
}
2026-05-14 14:38:34 +09:00
tradeAccessLoyaltyPenalty(city, center, polity) {
const cfg = SimConfig.polityAccess;
if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0;
2026-05-17 21:08:03 +09:00
const access = this.tradeAccessToCenter(city, center, polity.id);
if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07);
let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty;
const originEthnicity = this.dominantCityEthnicity(city);
for (const transitCity of access.transitCities) {
if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty;
const transitEthnicity = this.dominantCityEthnicity(transitCity);
if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) {
penalty += cfg.foreignTransitPenalty;
}
2026-05-14 14:38:34 +09:00
}
2026-05-17 21:08:03 +09:00
return clamp(penalty, 0, 0.07);
2026-05-13 20:21:58 +09:00
}
effectiveDistance(cityA, cityB) {
let distance = this.distanceBetweenCities(cityA, cityB);
if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55;
return distance;
}
createPolity(centerCity) {
const id = this.nextPolity++;
const polity = {
id,
centerCityId: centerCity.id,
2026-09-16 13:51:43 +09:00
cityIds: new Set(),
2026-05-13 20:21:58 +09:00
treasury: Math.max(0, centerCity.storedResources * 0.12),
color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
founded: this.year,
2026-09-16 20:53:17 +09:00
nextWarAllowedWeek: this.year + years(SimConfig.polity.foundingProtectionYears ?? 12),
2026-05-13 20:21:58 +09:00
legitimacy: this.rng.range(0.68, 0.94),
cohesion: this.rng.range(0.58, 0.9),
2026-05-17 21:08:03 +09:00
charisma: this.rng.range(SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5),
2026-05-13 20:21:58 +09:00
leaderStarted: this.year,
2026-05-17 21:08:03 +09:00
leaderTenureYears: this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68),
2026-05-13 20:21:58 +09:00
crisis: 0,
2026-09-16 20:53:17 +09:00
lastCrisisYear: this.year,
institutionalAging: 0,
lastInstitutionalReformYear: this.year
2026-05-13 20:21:58 +09:00
};
this.polities.push(polity);
this.polityById.set(polity.id, polity);
2026-09-16 13:51:43 +09:00
this.transferCityToPolity(centerCity, polity, {
loyalty: 1,
control: 0.72,
claim: 0.85,
reason: "founding"
});
2026-09-16 20:53:17 +09:00
this.enforcePolityTreasuryCap(polity);
2026-09-16 13:51:43 +09:00
this.claimPolityCoreTerritory(polity, centerCity, 4, "founding");
2026-05-13 20:21:58 +09:00
this.ensurePolityHistory(polity);
this.samplePolityHistory(polity);
return polity;
}
ensurePolityHistory(polity) {
if (!polity) return null;
if (!this.polityHistory.has(polity.id)) {
this.polityHistory.set(polity.id, {
id: polity.id,
color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62),
founded: polity.founded ?? this.year,
ended: null,
active: true,
centerCityId: polity.centerCityId ?? null,
fate: null,
samples: [],
events: [],
peakPower: 0,
peakYear: null,
peakEventYear: null
});
}
const history = this.polityHistory.get(polity.id);
history.events ??= [];
history.peakPower ??= 0;
history.peakYear ??= null;
history.peakEventYear ??= null;
return history;
}
averagePolityLoyalty(polity) {
const cities = this.getPolityCities(polity);
const subordinates = cities.filter(city => city.id !== polity.centerCityId);
if (!subordinates.length) return 1;
return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length;
}
samplePolityHistory(polity) {
const history = this.ensurePolityHistory(polity);
if (!history) return;
const cities = this.getPolityCities(polity);
const population = cities.reduce((sum, city) => sum + city.population, 0);
const treasury = polity.treasury || 0;
const avgLoyalty = this.averagePolityLoyalty(polity);
const fallbackPower = Math.sqrt(population) * 1.35 +
Math.sqrt(Math.max(0, treasury)) * 1.15 +
avgLoyalty * 16;
history.centerCityId = polity.centerCityId ?? history.centerCityId;
const sample = {
year: this.year,
cities: cities.length,
population,
treasury,
avgLoyalty,
2026-09-16 20:53:17 +09:00
institutionalAging: clamp(polity.institutionalAging ?? 0, 0, 1),
loyaltyCap: this.polityLoyaltyCap ? this.polityLoyaltyCap(polity) : 1,
2026-05-13 20:21:58 +09:00
power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower
};
history.samples.push(sample);
if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) {
history.peakPower = sample.power;
history.peakYear = this.year;
}
const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
while (history.samples.length > maxSamples) history.samples.shift();
}
samplePolityHistories() {
for (const polity of this.polities) this.samplePolityHistory(polity);
}
2026-09-16 20:53:17 +09:00
sampleWorldHistory() {
let regionalPopulation = 0;
for (const tile of this.activePopulationTiles) regionalPopulation += Math.max(0, this.world.population[tile] || 0);
const urbanPopulation = this.cities.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
const sample = {
year: this.year,
regionalPopulation,
urbanPopulation,
cities: this.cities.length,
polities: this.polities.length,
ethnicities: [...this.ethnicities.values()].filter(ethnicity => (ethnicity.population || 0) > 0).length
};
const last = this.worldHistory[this.worldHistory.length - 1];
if (last?.year === this.year) this.worldHistory[this.worldHistory.length - 1] = sample;
else this.worldHistory.push(sample);
const maxSamples = (SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears) * 4;
while (this.worldHistory.length > maxSamples) this.worldHistory.shift();
}
2026-05-13 20:21:58 +09:00
addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) {
let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null;
if (!history) {
const polity = this.getPolityById(polityId);
if (polity) history = this.ensurePolityHistory(polity);
}
if (!history) return;
history.events ??= [];
const sameYearDuplicate = history.events.some(event =>
event.type === type &&
event.year === year &&
JSON.stringify(event.data || {}) === JSON.stringify(data || {})
);
if (sameYearDuplicate) return;
history.events.push({ year, type, importance, data });
while (history.events.length > 120) history.events.shift();
}
2026-05-14 14:38:34 +09:00
addGraphEvent(type, year = this.year, data = {}, importance = 2) {
this.graphEvents ??= [];
const duplicate = this.graphEvents.some(event =>
event.type === type &&
event.year === year &&
JSON.stringify(event.data || {}) === JSON.stringify(data || {})
);
if (duplicate) return;
this.graphEvents.push({ year, type, importance, data });
while (this.graphEvents.length > 160) this.graphEvents.shift();
}
2026-05-13 20:21:58 +09:00
selectNewLeader(polity, forced = false) {
if (!polity) return;
2026-05-17 21:08:03 +09:00
const minCharisma = SimConfig.polity.charismaMin ?? 0.5;
const maxCharisma = SimConfig.polity.charismaMax ?? 1.5;
const avgCharisma = SimConfig.polity.charismaAverage ?? 1;
const previousCharisma = clamp(polity.charisma ?? avgCharisma, minCharisma, maxCharisma);
2026-05-13 20:21:58 +09:00
const center = this.getCityById(polity.centerCityId);
const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5;
const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3;
2026-05-17 21:08:03 +09:00
const randomLeader = this.rng.range(minCharisma, maxCharisma);
2026-05-13 20:21:58 +09:00
const continuity = forced ? 0.18 : 0.34;
const institutionalPull = 0.82 + institutionalBias * 0.36;
const nextCharisma = clamp(
previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull,
2026-05-17 21:08:03 +09:00
minCharisma,
maxCharisma
2026-05-13 20:21:58 +09:00
);
polity.charisma = nextCharisma;
polity.leaderStarted = this.year;
2026-05-17 21:08:03 +09:00
polity.leaderTenureYears = this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 22, SimConfig.polity.leaderTenureMaxYears ?? 72);
2026-05-13 20:21:58 +09:00
const change = nextCharisma - previousCharisma;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1);
if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5);
}
updatePolityLeaders() {
for (const polity of this.polities) {
2026-05-17 21:08:03 +09:00
polity.charisma = clamp(polity.charisma ?? (SimConfig.polity.charismaAverage ?? 1), SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5);
2026-05-13 20:21:58 +09:00
polity.leaderStarted ??= polity.founded ?? this.year;
2026-05-17 21:08:03 +09:00
polity.leaderTenureYears ??= this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68);
2026-05-14 00:21:24 +09:00
const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR;
2026-05-13 20:21:58 +09:00
const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears);
const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09);
const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36);
if (successionChance > 0 && this.rng.next() < successionChance) {
this.selectNewLeader(polity, (polity.crisis || 0) > 0.85);
}
}
}
maybeSpawnDisaster() {
const config = SimConfig.disaster;
const interval = years(config?.checkIntervalYears ?? 8);
if (!interval || this.year % interval !== 0) return;
if (this.rng.next() > (config?.baseChance ?? 0.38)) return;
this.spawnDisaster();
}
spawnDisaster() {
const config = SimConfig.disaster;
const large = this.rng.next() < (config?.rareLargeChance ?? 0.10);
const radius = large
? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54)
: this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34);
const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95);
this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity);
}
applyDisaster(x, y, radius, intensity) {
const config = SimConfig.disaster;
const w = this.world;
const scaledIntensity = intensity * (config?.damageScale ?? 1);
const majorLossRate = config?.majorLossRate ?? 0.05;
const affectedPolityMap = new Map();
const affectedPolityIds = new Set();
for (const city of this.cities) {
if (city.polityId === null) continue;
if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId);
}
const polityPopulationBefore = new Map();
for (const id of affectedPolityIds) {
const polity = this.getPolityById(id);
if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity));
}
let affectedCities = 0;
let totalPopulationLoss = 0;
2026-09-17 22:32:25 +09:00
let regionalPopulationLoss = 0;
2026-05-13 20:21:58 +09:00
for (const city of this.cities) {
const d = Math.hypot(city.x - x, city.y - y);
if (d > radius) continue;
const falloff = 1 - d / radius;
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
if (damage <= 0) continue;
affectedCities++;
const popBefore = city.population || 0;
const lossRate = clamp(damage * 0.25, 0, 0.45);
const loss = Math.floor(popBefore * lossRate);
city.population = Math.max(0, popBefore - loss);
if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss);
city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55));
city.strength = Math.max(0, city.strength - damage * 0.38);
this.deaths += Math.floor(loss * 0.35);
totalPopulationLoss += loss;
if (city.polityId !== null) {
let entry = affectedPolityMap.get(city.polityId);
if (!entry) {
entry = {
polityId: city.polityId,
populationBefore: polityPopulationBefore.get(city.polityId) || 0,
populationLoss: 0,
cityIds: new Set()
};
affectedPolityMap.set(city.polityId, entry);
}
entry.populationLoss += loss;
entry.cityIds.add(city.id);
const polity = this.getPolityById(city.polityId);
if (polity) {
polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1);
}
}
}
const minX = Math.max(0, Math.floor(x - radius));
const maxX = Math.min(w.size - 1, Math.ceil(x + radius));
const minY = Math.max(0, Math.floor(y - radius));
const maxY = Math.min(w.size - 1, Math.ceil(y + radius));
for (let ty = minY; ty <= maxY; ty++) {
for (let tx = minX; tx <= maxX; tx++) {
const d = Math.hypot(tx - x, ty - y);
if (d > radius) continue;
const falloff = 1 - d / radius;
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
const tile = w.idx(tx, ty);
2026-09-17 22:32:25 +09:00
if (w.terrain[tile] !== Terrain.WATER && (w.population[tile] || 0) > 0) {
const regionalLossRate = clamp(
damage * (config?.regionalPopulationLossRate ?? 0.42),
0,
config?.regionalPopulationLossMax ?? 0.65
);
const loss = this.removeEthnicPopulationProportionally(tile, w.population[tile] * regionalLossRate);
regionalPopulationLoss += loss;
totalPopulationLoss += loss;
this.deaths += Math.floor(loss * 0.40);
}
2026-05-13 20:21:58 +09:00
w.resource[tile] *= (1 - damage * 0.45);
w.farmland[tile] *= (1 - damage * 0.35);
w.pheromone[tile] *= (1 - damage * 0.25);
}
}
const affectedPolities = [...affectedPolityMap.values()].map(entry => {
const populationBefore = Math.max(1, entry.populationBefore || 0);
const lossRate = entry.populationLoss / populationBefore;
return {
polityId: entry.polityId,
populationBefore: entry.populationBefore,
populationLoss: entry.populationLoss,
lossRate,
cityIds: [...entry.cityIds]
};
});
const id = this.nextDisaster++;
const record = {
id,
year: this.year,
x,
y,
radius,
intensity,
expires: this.year + years(config?.visualDurationYears ?? 18),
affectedCities,
totalPopulationLoss,
2026-09-17 22:32:25 +09:00
regionalPopulationLoss,
2026-05-13 20:21:58 +09:00
affectedPolities
};
this.disasters.push(record);
this.disasterHistory.push(record);
for (const entry of affectedPolities) {
if (entry.lossRate >= majorLossRate) {
this.addPolityEvent(entry.polityId, "disaster", this.year, {
disasterId: id,
populationLoss: entry.populationLoss,
lossRate: entry.lossRate,
affectedCities: entry.cityIds.length
}, entry.lossRate >= majorLossRate * 2 ? 3 : 2);
}
}
this.disasters = this.disasters
.filter(disaster => this.year <= disaster.expires)
.slice(-(config?.maxActiveVisuals ?? 12));
while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift();
}
2026-09-16 20:53:17 +09:00
triggerManualDisasterAt(x, y) {
const w = this.world;
const tx = clamp(Math.floor(x), 0, w.size - 1);
const ty = clamp(Math.floor(y), 0, w.size - 1);
const cfg = SimConfig.disaster || {};
const radius = this.rng.range(Math.max(5, cfg.minRadius ?? 6), Math.min(24, cfg.maxRadius ?? 34));
const intensity = this.rng.range(Math.max(0.48, cfg.minIntensity ?? 0.25), Math.min(0.82, cfg.maxIntensity ?? 0.95));
const beforeId = this.nextDisaster;
this.applyDisaster(tx, ty, radius, intensity);
const record = this.disasterHistory.find(item => item.id === beforeId) || this.disasterHistory[this.disasterHistory.length - 1];
return { ok: true, record, x: tx, y: ty, radius, intensity };
}
manualCityEthnicitySeed(tile) {
const w = this.world;
const local = this.tileEthnicMix.get(tile);
if (local?.size) return new Map(local);
const dominant = w.dominantEthnicity[tile];
if (dominant >= 0) return new Map([[dominant, 1]]);
const tx = tile % w.size;
const ty = Math.floor(tile / w.size);
let nearest = null;
let nearestDistance = Infinity;
for (const [cultureTile, mix] of this.tileEthnicMix) {
if (!mix?.size) continue;
const distance = Math.abs(cultureTile % w.size - tx) + Math.abs(Math.floor(cultureTile / w.size) - ty);
if (distance < nearestDistance) {
nearest = mix;
nearestDistance = distance;
if (distance <= 2) break;
}
}
if (nearest?.size) return new Map(nearest);
let nearestCity = null;
let cityDistance = Infinity;
for (const city of this.cities) {
if (!city.ethnicityComposition?.size) continue;
const distance = Math.abs(city.x - tx) + Math.abs(city.y - ty);
if (distance < cityDistance) {
nearestCity = city;
cityDistance = distance;
}
}
if (nearestCity) return new Map(nearestCity.ethnicityComposition);
const ethnicityId = this.createEthnicity(0, {
temperature: w.temperature[tile],
humidity: w.humidity[tile]
});
const ethnicity = this.ethnicities.get(ethnicityId);
if (ethnicity) ethnicity.averageTraits = this.randomTraits();
return new Map([[ethnicityId, 1]]);
}
applyCityEthnicitySeed(city, seed, population) {
city.ethnicityComposition ??= new Map();
city.ethnicityComposition.clear();
let total = 0;
for (const count of seed?.values?.() || []) total += Math.max(0, count || 0);
if (total <= 0) return;
let assigned = 0;
const entries = [...seed.entries()];
for (let index = 0; index < entries.length; index++) {
const [id, count] = entries[index];
const amount = index === entries.length - 1
? Math.max(0, population - assigned)
: Math.max(0, Math.round(population * Math.max(0, count || 0) / total));
if (amount <= 0) continue;
city.ethnicityComposition.set(id, amount);
assigned += amount;
}
}
buildCityAt(x, y) {
const w = this.world;
const tx = clamp(Math.floor(x), 0, w.size - 1);
const ty = clamp(Math.floor(y), 0, w.size - 1);
const tile = w.idx(tx, ty);
// Manual intervention bypasses natural spacing and the automatic-city cap,
// but cities still require land.
if (w.terrain[tile] === Terrain.WATER) return { ok: false, reason: "water", x: tx, y: ty };
const ethnicitySeed = this.manualCityEthnicitySeed(tile);
const city = this.createCity(tx, ty);
const basePopulation = city.population;
const targetPopulation = Math.max(1, SimConfig.city.initialPopulation ?? 400);
this.applyCityEthnicitySeed(city, ethnicitySeed, targetPopulation);
city.population = targetPopulation;
city.peakPopulation = targetPopulation;
city.storedResources = Math.max(city.storedResources, targetPopulation * 0.20);
city.strength = Math.max(city.strength, 6);
city.activeVisitors = Math.max(city.activeVisitors, 12);
if (targetPopulation > basePopulation) {
city.population = targetPopulation - basePopulation;
this.projectCityPopulationToTiles(city);
city.population = targetPopulation;
}
this.cities.push(city);
this.rebuildIndexes();
w.city[tile] = city.id;
this.assignNewCityToTerritoryOwner(city, tile, { explicit: true });
if (city.polityId !== null) {
const polity = this.getPolityById(city.polityId);
if (polity) this.claimPolityCoreTerritory(polity, city, 3, "manualCity");
}
this.updatePopulationCapacity();
this.syncTilePopulationCulture();
return { ok: true, city };
}
2026-05-13 20:21:58 +09:00
markPolityEnded(polity, reason = "dissolved") {
if (!polity) return;
const history = this.ensurePolityHistory(polity);
if (!history) return;
this.samplePolityHistory(polity);
history.ended = this.year;
history.active = false;
history.fate = reason;
history.centerCityId = polity.centerCityId ?? history.centerCityId;
this.polityHistory.delete(polity.id);
this.deadPolityHistories.push(history);
2026-09-16 20:53:17 +09:00
while (this.deadPolityHistories.length > 20) this.deadPolityHistories.shift();
2026-05-13 20:21:58 +09:00
}
getAllPolityHistories() {
return [...this.deadPolityHistories, ...this.polityHistory.values()]
.sort((a, b) => (a.founded - b.founded) || (a.id - b.id));
}
addCityToPolity(city, polity, initialLoyalty = 0.5) {
if (!city || !polity) return;
if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city);
city.polityId = polity.id;
city.loyalty = clamp(initialLoyalty, 0, 1);
city.receivedAid = false;
polity.cityIds.add(city.id);
2026-09-17 22:32:25 +09:00
this.polityCitiesCache?.delete(polity.id);
2026-05-13 20:21:58 +09:00
}
removeCityFromPolity(city) {
if (!city || city.polityId === null) return;
const oldPolity = this.getPolityById(city.polityId);
2026-09-17 22:32:25 +09:00
if (oldPolity) {
oldPolity.cityIds.delete(city.id);
this.polityCitiesCache?.delete(oldPolity.id);
}
2026-05-13 20:21:58 +09:00
city.polityId = null;
city.loyalty = 0.45;
city.receivedAid = false;
}
isPolityAtWar(polityId) {
return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId));
}
2026-05-14 14:38:34 +09:00
warCountForPolity(polityId) {
return this.wars.filter(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)).length;
}
canBeWarTarget(polityId) {
return this.warCountForPolity(polityId) < 3;
}
2026-05-13 20:21:58 +09:00
getWarBetween(aId, bId) {
return this.wars.find(war =>
war.ended === null &&
((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId))
) || null;
}
startWar(a, b) {
if (!a || !b || a.id === b.id) return null;
2026-09-16 20:53:17 +09:00
if (this.year < (a.nextWarAllowedWeek ?? 0) || this.year < (b.nextWarAllowedWeek ?? 0)) return null;
if (this.wars.filter(war => war.ended === null).length >= SimConfig.war.maxActiveWars) return null;
2026-05-13 20:21:58 +09:00
if (this.getWarBetween(a.id, b.id)) return null;
2026-05-14 14:38:34 +09:00
if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null;
2026-05-13 20:21:58 +09:00
const id = this.nextWar++;
2026-09-16 13:51:43 +09:00
const goal = this.chooseWarGoal(a, b);
2026-05-13 20:21:58 +09:00
const war = {
id,
aPolityId: a.id,
bPolityId: b.id,
2026-09-16 13:51:43 +09:00
goal,
2026-05-13 20:21:58 +09:00
started: this.year,
lastActionYear: this.year,
2026-09-16 20:53:17 +09:00
intensity: this.rng.range(0.9, 1.4),
2026-05-13 20:21:58 +09:00
exhaustionA: 0,
exhaustionB: 0,
2026-09-16 13:51:43 +09:00
scoreA: 0,
scoreB: 0,
capturedByA: 0,
capturedByB: 0,
cityPressureById: new Map(),
lastFrontAdvanceYear: new Map(),
2026-05-13 20:21:58 +09:00
ended: null
};
this.wars.push(war);
2026-09-16 20:53:17 +09:00
this.addPolityEvent(a.id, "war", this.year, { outcome: "started", targetPolityId: b.id }, 2);
this.addPolityEvent(b.id, "war", this.year, { outcome: "started", targetPolityId: a.id }, 2);
2026-05-13 20:21:58 +09:00
a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5);
b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5);
return war;
}
2026-09-16 13:51:43 +09:00
chooseWarGoal(a, b) {
const aPower = Math.max(1, this.polityPower(a));
const bPower = Math.max(1, this.polityPower(b));
const ratio = aPower / bPower;
const defenderInstability = this.polityInstability ? this.polityInstability(b) : 0;
const defenderOverextension = this.polityOverextension ? this.polityOverextension(b) : 0;
const defenderLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5;
const distance = this.polityDistance(a, b);
const hasReachableCity = this.bestWarPressureTarget(a, b, { goal: "city_conquest", intensity: 0.8, cityPressureById: new Map() }, aPower, bPower, true);
if (ratio > 2.2 && (defenderInstability > 0.6 || defenderOverextension > 0.8 || defenderLoyalty < 0.38)) return "collapse_exploitation";
if (ratio > 1.75 && defenderInstability > 0.45 && hasReachableCity && this.rng.next() < 0.22) return "capital_pressure";
if (ratio > 1.25 && hasReachableCity && this.rng.next() < 0.48) return "city_conquest";
if (distance < 24 && this.rng.next() < 0.62) return "border_claim";
return "punitive_raid";
}
2026-05-13 20:21:58 +09:00
endWar(war) {
if (!war || war.ended !== null) return;
2026-09-16 13:51:43 +09:00
this.applyWarSettlement(war);
2026-05-13 20:21:58 +09:00
war.ended = this.year;
2026-09-16 20:53:17 +09:00
for (const [id, other] of [[war.aPolityId, war.bPolityId], [war.bPolityId, war.aPolityId]]) {
const polity = this.getPolityById(id);
if (polity) polity.nextWarAllowedWeek = Math.max(polity.nextWarAllowedWeek || 0, this.year + SimConfig.war.cooldownWeeks);
this.addPolityEvent(id, "war", this.year, { outcome: "ended", targetPolityId: other }, 2);
}
for (const campaign of this.campaigns) {
if (campaign.status === "active" && campaign.type === "border_war" &&
((campaign.attackerPolityId === war.aPolityId && campaign.defenderPolityId === war.bPolityId) ||
(campaign.attackerPolityId === war.bPolityId && campaign.defenderPolityId === war.aPolityId))) campaign.status = "ceased";
}
2026-05-13 20:21:58 +09:00
}
totalPolityPopulation(polity) {
return this.getPolityCities(polity)
.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
}
averagePolityTechnology(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
let total = 0;
let count = 0;
for (const city of cities) {
if (!city?.knowledge) continue;
total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5;
count++;
}
return count ? total / count : 0;
}
polityPower(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0);
const treasury = Math.max(0, polity.treasury || 0);
const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5;
const tech = this.averagePolityTechnology(polity);
const instability = this.polityInstability ? this.polityInstability(polity) : 0;
const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0;
const basePower = Math.max(0,
Math.sqrt(population) * 1.35 +
Math.sqrt(treasury) * 1.15 +
avgLoyalty * 16 +
tech * 24 -
instability * 12 -
agePressure * 6
);
2026-05-17 21:08:03 +09:00
return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5);
2026-05-13 20:21:58 +09:00
}
polityInfluenceRange(polity, wartime = false) {
const power = this.polityPower(polity);
const base = 18 + Math.sqrt(power) * 2.2;
const range = wartime ? base * 1.15 : base * 0.75;
return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42);
}
nearestPolityCity(polity, city) {
if (!polity || !city) return null;
let best = null;
let bestDistance = Infinity;
for (const source of this.getPolityCities(polity)) {
const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
if (distance < bestDistance) {
best = source;
bestDistance = distance;
}
}
return best;
}
polityConnectionToCity(polity, city) {
if (!polity || !city) return { source: null, distance: Infinity, connected: false };
let source = null;
let distance = Infinity;
for (const c of this.getPolityCities(polity)) {
if (c.id === city.id) continue;
const d = this.effectiveDistance(c, city);
if (d < distance) {
source = c;
distance = d;
}
}
const limit = SimConfig.polity.logisticsDistance;
return { source, distance, connected: !!source && distance <= limit };
}
polityDistance(a, b) {
const aCities = this.getPolityCities(a);
const bCities = this.getPolityCities(b);
if (!aCities.length || !bCities.length) return Infinity;
let best = Infinity;
for (const aCity of aCities) {
for (const bCity of bCities) {
const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity);
if (distance < best) best = distance;
}
}
return best;
}
2026-09-16 20:53:17 +09:00
polityTreasuryCap(polity) {
if (!polity) return 0;
const perPopulation = Math.max(0, SimConfig.polity?.treasuryCapPerPopulation ?? 0.30);
return Math.max(0, this.totalPolityPopulation(polity) * perPopulation);
}
enforcePolityTreasuryCap(polity) {
if (!polity) return 0;
const cap = this.polityTreasuryCap(polity);
polity.treasury = clamp(polity.treasury || 0, 0, cap);
return cap;
}
enforcePolityTreasuryCaps() {
for (const polity of this.polities) this.enforcePolityTreasuryCap(polity);
}
treasuryCityInvestmentProfile(cost) {
const cfg = SimConfig.polity || {};
const safeCost = Math.max(0, cost || 0);
const basePopulation = Math.max(1, SimConfig.city.initialPopulation ?? 400);
const populationPerCost = Math.max(0, cfg.treasuryCityPopulationPerCost ?? 0.18);
const reservePerCost = Math.max(0, cfg.treasuryCityReservePerCost ?? 0.45);
const population = Math.max(basePopulation, Math.round(basePopulation + safeCost * populationPerCost));
const resources =
34 * SimConfig.city.populationScale +
safeCost * reservePerCost +
Math.max(0, population - basePopulation) * 0.22;
return { population, resources };
}
findTreasuryCityFoundingSite(polity) {
if (!polity) return -1;
const w = this.world;
const cities = this.getPolityCities(polity);
if (!cities.length) return -1;
const spacing = Math.max(1, SimConfig.city.naturalFoundingMinDistance ?? 6);
const radius = Math.max(spacing + 1, SimConfig.polity?.treasuryCityFoundingSearchRadius ?? 20);
const candidates = [];
const visited = new Set();
for (const source of cities) {
for (const { dx, dy, distance } of this.getRadiusOffsets(radius, false)) {
if (distance < spacing) continue;
const x = source.x + dx;
const y = source.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (visited.has(tile)) continue;
visited.add(tile);
if (w.terrain[tile] === Terrain.WATER || w.city[tile] >= 0) continue;
if (w.territoryOwner[tile] !== polity.id) continue;
if (this.getCitiesNear(x, y, spacing - 1).length) continue;
const route = w.tradeRoute[tile] ? 1.25 : clamp((w.pheromone[tile] || 0) / 12, 0, 0.8);
const score =
(w.fertility[tile] || 0) * 3.2 +
clamp((w.resource[tile] || 0) / 20, 0, 2.5) +
(w.populationPressure[tile] || 0) * 1.6 +
(w.farmland[tile] || 0) * 1.4 +
(w.control[tile] || 0) * 1.2 +
route -
(w.move[tile] || 1) * 0.75;
candidates.push({ tile, score });
}
}
if (!candidates.length) return -1;
2026-09-17 22:32:25 +09:00
let bestScore = -Infinity;
for (const candidate of candidates) if (candidate.score > bestScore) bestScore = candidate.score;
2026-09-16 20:53:17 +09:00
const scoreWindow = Math.max(0.2, SimConfig.polity?.treasuryCitySiteScoreWindow ?? 6.0);
const floor = bestScore - scoreWindow;
let totalWeight = 0;
2026-09-17 22:32:25 +09:00
for (const candidate of candidates) {
if (candidate.score < floor) continue;
2026-09-16 20:53:17 +09:00
candidate.weight = Math.pow(Math.max(0.20, candidate.score - floor + 0.20), 1.10);
totalWeight += candidate.weight;
}
let roll = this.rng.next() * totalWeight;
2026-09-17 22:32:25 +09:00
let fallback = candidates[0].tile;
for (const candidate of candidates) {
if (candidate.score < floor) continue;
fallback = candidate.tile;
2026-09-16 20:53:17 +09:00
roll -= candidate.weight;
if (roll <= 0) return candidate.tile;
}
2026-09-17 22:32:25 +09:00
return fallback;
2026-09-16 20:53:17 +09:00
}
maybeBuildTreasuryCities() {
const cfg = SimConfig.polity || {};
const chance = clamp(cfg.treasuryCityFoundingChance ?? 0.14, 0, 1);
const costRatio = clamp(cfg.treasuryCityFoundingCostRatio ?? 0.35, 0, 1);
if (chance <= 0 || costRatio <= 0 || this.cities.length >= SimConfig.city.maxCities) return 0;
let founded = 0;
for (const polity of [...this.polities]) {
if (this.cities.length >= SimConfig.city.maxCities) break;
const cap = this.polityTreasuryCap(polity);
if (cap <= 0 || (polity.treasury || 0) + 1e-6 < cap) continue;
if (this.rng.next() >= chance) continue;
const tile = this.findTreasuryCityFoundingSite(polity);
if (tile < 0) continue;
const cost = Math.min(polity.treasury || 0, cap * costRatio);
if (cost <= 0) continue;
const x = tile % this.world.size;
const y = Math.floor(tile / this.world.size);
const ethnicitySeed = this.manualCityEthnicitySeed(tile);
const investment = this.treasuryCityInvestmentProfile(cost);
const city = this.createCity(x, y, null, {
initialPopulation: investment.population,
initialResources: investment.resources
});
this.applyCityEthnicitySeed(city, ethnicitySeed, city.population);
city.strength = Math.max(city.strength, 4.5);
city.activeVisitors = Math.max(city.activeVisitors, 6);
this.cities.push(city);
this.cityById.set(city.id, city);
const bucketKey = this.cityBucketKey(city.x, city.y);
if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
this.cityBuckets.get(bucketKey).push(city);
this.world.city[tile] = city.id;
this.addCityToPolity(city, polity, 0.72);
this.claimPolityCoreTerritory(polity, city, 3, "treasuryCity");
polity.treasury = Math.max(0, (polity.treasury || 0) - cost);
founded++;
}
if (founded) {
this.rebuildIndexes();
this.updatePopulationCapacity();
this.syncTilePopulationCulture();
}
return founded;
}
2026-05-13 20:21:58 +09:00
polityAge(polity) {
2026-05-14 00:21:24 +09:00
return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR;
2026-05-13 20:21:58 +09:00
}
polityAgePressure(polity) {
const age = this.polityAge(polity);
if (age < 80) return 0;
const x = (age - 80) / 420;
return clamp(1 - Math.exp(-x), 0, 1.35);
}
2026-09-16 20:53:17 +09:00
polityInstitutionalAging(polity) {
return clamp(polity?.institutionalAging ?? 0, 0, 1);
}
polityLoyaltyCap(polity) {
return clamp(1 - this.polityInstitutionalAging(polity), 0, 1);
}
updateInstitutionalAging() {
const cfg = SimConfig.polity || {};
const annualIncrease = Math.max(0, cfg.institutionalAgingAnnualIncrease ?? 0.0015);
for (const polity of this.polities) {
polity.institutionalAging = this.polityInstitutionalAging(polity);
if (this.year <= (polity.founded ?? this.year)) continue;
polity.institutionalAging = clamp(polity.institutionalAging + annualIncrease, 0, 1);
}
}
maybeTriggerInstitutionalReforms() {
const cfg = SimConfig.polity || {};
const threshold = clamp(cfg.institutionalReformThreshold ?? 0.30, 0, 1);
const cooldown = years(Math.max(0, cfg.institutionalReformCooldownYears ?? 50));
for (const polity of this.polities) {
const before = this.polityInstitutionalAging(polity);
if (before <= threshold) continue;
if (this.year - (polity.lastInstitutionalReformYear ?? polity.founded ?? 0) < cooldown) continue;
const chance = clamp(
(cfg.institutionalReformBaseChance ?? 0.008) + before * (cfg.institutionalReformChanceScale ?? 0.10),
0,
cfg.institutionalReformMaxChance ?? 0.12
);
if (this.rng.next() >= chance) continue;
const changeRate = this.rng.range(
cfg.institutionalReformMinChange ?? -0.50,
cfg.institutionalReformMaxChange ?? 0.30
);
const after = clamp(before * (1 + changeRate), 0, 1);
polity.institutionalAging = after;
polity.lastInstitutionalReformYear = this.year;
this.addPolityEvent(polity.id, "institutionalReform", this.year, {
before,
after,
changeRate,
loyaltyCapBefore: clamp(1 - before, 0, 1),
loyaltyCapAfter: clamp(1 - after, 0, 1)
}, 3);
}
}
2026-05-13 20:21:58 +09:00
polityOverextension(polity) {
const cities = this.getPolityCities(polity);
const center = this.getCityById(polity.centerCityId);
if (!center || cities.length <= 1) return 0;
const cityCountPressure = Math.max(0, cities.length - 5) * 0.08;
let distanceSum = 0;
let count = 0;
for (const city of cities) {
if (city.id === center.id) continue;
distanceSum += this.effectiveDistance(center, city);
count++;
}
const avgDistance = count ? distanceSum / count : 0;
const distancePressure = Math.max(0, avgDistance - 28) * 0.008;
return clamp(cityCountPressure + distancePressure, 0, 2);
}
polityResourceStress(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 1;
let poor = 0;
let supplyStress = 0;
for (const city of cities) {
const perCapita = city.storedResources / Math.max(1, city.population);
if (perCapita < 0.06) poor++;
supplyStress += city.supplyStress || 0;
}
const povertyRate = poor / cities.length;
const avgSupplyStress = supplyStress / cities.length;
const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length);
const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0;
return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5);
}
polityLogisticsStress(polity) {
const cities = this.getPolityCities(polity);
const center = this.getCityById(polity.centerCityId);
if (!center || cities.length <= 1) return 0;
let stress = 0;
let count = 0;
for (const city of cities) {
if (city.id === center.id) continue;
const distance = this.effectiveDistance(center, city);
const food = city.storedResources / Math.max(1, city.population);
const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012;
const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5;
const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1;
stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief;
count++;
}
return clamp(stress / Math.max(1, count), 0, 1.8);
}
polityEthnicFragmentation(polity) {
const cities = this.getPolityCities(polity);
if (cities.length <= 1) return 0;
const counts = new Map();
for (const city of cities) {
const e = this.dominantCityEthnicity(city);
if (e !== null) counts.set(e, (counts.get(e) || 0) + 1);
}
if (!counts.size) return 0;
let max = 0;
for (const v of counts.values()) max = Math.max(max, v);
const dominantShare = max / cities.length;
return clamp(1 - dominantShare, 0, 1);
}
polityInstability(polity) {
const agePressure = this.polityAgePressure(polity);
const overextension = this.polityOverextension(polity);
const resourceStress = this.polityResourceStress(polity);
const logisticsStress = this.polityLogisticsStress(polity);
const fragmentation = this.polityEthnicFragmentation(polity);
const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85;
const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62;
return clamp(
agePressure * 0.35 +
overextension * 0.24 +
resourceStress * 0.38 +
logisticsStress * 0.30 +
fragmentation * 0.22 +
(polity.crisis || 0) * 0.42 -
legitimacyBuffer -
cohesionBuffer,
0,
2.5
);
}
foundPolities() {
2026-05-17 21:08:03 +09:00
let formed = 0;
2026-05-13 20:21:58 +09:00
for (const city of this.cities) {
if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue;
2026-09-16 13:51:43 +09:00
if (this.world.territoryOwner[this.world.idx(city.x, city.y)] >= 0) continue;
2026-05-17 21:08:03 +09:00
if (formed >= 2) break;
const localPeers = this.getCitiesNear(city.x, city.y, 18).filter(other => other !== city && other.polityId === null);
const localLeader = localPeers.every(other => this.cityInfluence(city) >= this.cityInfluence(other) * 0.92);
if (!localLeader || this.rng.next() > 0.22) continue;
this.createPolity(city);
formed++;
2026-05-13 20:21:58 +09:00
}
}
2026-05-17 21:08:03 +09:00
// Phase 1B: campaigns are discrete targeted events. Influence/control can show
// continuous territory, but new holdings come from these active campaign objects.
maybeStartCampaigns() {
const cfg = SimConfig.campaign || {};
const interval = years(cfg.checkIntervalYears ?? 3);
if (!interval || this.year % interval !== 0) return;
const active = (this.campaigns || []).filter(c => c.status === "active").length;
if (active >= (cfg.maxActiveCampaigns ?? 12)) return;
for (const polity of this.polities) {
if ((this.campaigns || []).filter(c => c.status === "active").length >= (cfg.maxActiveCampaigns ?? 12)) break;
if (!this.getPolityCities(polity).length) continue;
const cooldown = this.year - (polity.lastCampaignYear ?? -Infinity);
if (cooldown < years(cfg.minCooldownYears ?? 12)) continue;
if (polity.nextCampaignAllowedYear != null && this.year < polity.nextCampaignAllowedYear) continue;
const chance = this.campaignStartChance(polity);
if (this.rng.next() > chance) continue;
const target = this.chooseCampaignTarget(polity);
if (target) this.startCampaign(polity, target);
}
}
campaignStartChance(polity) {
const cfg = SimConfig.campaign || {};
const power = this.polityPower(polity);
const powerFactor = clamp(power / 80, 0.5, 2.0);
const cohesion = clamp(polity.cohesion ?? 0.6, 0.25, 1.2);
const legitimacy = clamp(polity.legitimacy ?? 0.7, 0.25, 1.2);
const charisma = clamp(polity.charisma ?? 1, 0.5, 1.8);
const crisis = clamp(polity.crisis || 0, 0, 1.5);
const overextension = this.polityOverextension ? this.polityOverextension(polity) : 0;
const overextensionFactor = clamp(1.2 - overextension, 0.1, 1.2);
const crisisFactor = clamp(1.1 - crisis, 0.05, 1.1);
const treasuryFactor = clamp((polity.treasury || 0) / Math.max(8, this.getPolityCities(polity).length * 7), 0.45, 1.35);
const noise = this.rng.range(0.72, 1.24);
return clamp((cfg.baseChance ?? 0.18) * powerFactor * cohesion * legitimacy * charisma * overextensionFactor * crisisFactor * treasuryFactor * noise, 0, 0.68);
}
weightedPick(entries) {
const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.weight || 0), 0);
if (total <= 0) return null;
let roll = this.rng.next() * total;
for (const entry of entries) {
roll -= Math.max(0, entry.weight || 0);
if (roll <= 0) return entry;
}
return entries[entries.length - 1] || null;
}
chooseCampaignTarget(polity) {
const cfg = SimConfig.campaign || {};
const groups = [
{ kind: "frontier_colonization", weight: cfg.frontierTargetChance ?? 0.35, items: this.frontierCampaignTargets(polity) },
{ kind: "nonstate_subjugation", weight: cfg.nonStateTargetChance ?? 0.30, items: this.nonStateCampaignTargets(polity) },
{ kind: "independent_city_annexation", weight: cfg.independentCityTargetChance ?? 0.20, items: this.independentCityCampaignTargets(polity) },
{ kind: "border_war", weight: cfg.polityTargetChance ?? 0.15, items: this.borderWarCampaignTargets(polity) }
].filter(group => group.items.length && group.weight > 0);
const group = this.weightedPick(groups);
if (!group) return null;
const target = this.weightedPick(group.items);
return target ? { ...target, type: group.kind } : null;
}
noisyTargetWeight(score) {
return Math.max(0, score * this.rng.range(0.45, 1.65) + this.rng.range(-0.08, 0.12));
}
frontierCampaignTargets(polity) {
return this.sampleTileCampaignTargets(polity, (tile, connection) => {
const w = this.world;
if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null;
2026-09-16 13:51:43 +09:00
const expansion = this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 6, requireUnowned: true });
if (!expansion) return null;
2026-05-17 21:08:03 +09:00
const nearbyTrade = (w.tradeRoute[tile] ? 0.5 : 0) + clamp(w.pheromone[tile] / 12, 0, 0.45);
const resistance = Math.sqrt(Math.max(0, w.population[tile] || 0)) * 0.10;
2026-09-16 13:51:43 +09:00
const frontierDistance = this.expansionConnectionDistance(polity, tile, 6);
const value = w.fertility[tile] + clamp(w.resource[tile] / 45, 0, 1) * 0.8 + nearbyTrade + w.mineral[tile] * 0.25 - resistance - frontierDistance * 0.10 - connection.distance * 0.010 - w.move[tile] * 0.22;
2026-05-17 21:08:03 +09:00
if (value < 0.38) return null;
return { targetTile: tile, weight: this.noisyTargetWeight(value) };
});
}
nonStateCampaignTargets(polity) {
return this.sampleTileCampaignTargets(polity, (tile, connection) => {
const w = this.world;
if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null;
2026-09-16 13:51:43 +09:00
const expansion = this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 7, requireUnowned: true });
if (!expansion) return null;
2026-09-16 20:53:17 +09:00
const population = w.population?.[tile] || 0;
2026-05-18 17:13:18 +09:00
const ethnicity = w.dominantEthnicity[tile];
2026-05-17 21:08:03 +09:00
if (population < 2 && ethnicity < 0) return null;
const diversity = w.cultureDiversity[tile] || 0;
const sourceEthnicity = this.dominantCityEthnicity(connection.source);
const cultureMismatch = sourceEthnicity !== null && ethnicity >= 0 && sourceEthnicity !== ethnicity ? 0.45 : 0;
const strategic = (w.tradeRoute[tile] ? 0.45 : 0) + w.mineral[tile] * 0.38 + w.fertility[tile] * 0.25;
2026-09-16 13:51:43 +09:00
const frontierDistance = this.expansionConnectionDistance(polity, tile, 7);
const score = Math.sqrt(Math.max(1, population)) * 0.38 + strategic + clamp(w.resource[tile] / 45, 0, 1) * 0.35 - frontierDistance * 0.09 - connection.distance * 0.008 - w.move[tile] * 0.22 - cultureMismatch + diversity * 0.12;
2026-05-17 21:08:03 +09:00
return {
targetTile: tile,
targetEthnicity: ethnicity >= 0 ? ethnicity : null,
weight: this.noisyTargetWeight(score)
};
});
}
independentCityCampaignTargets(polity) {
const cfg = SimConfig.campaign || {};
const targets = [];
for (const city of this.cities) {
if (city.polityId !== null || city.population <= 0) continue;
const connection = this.polityConnectionToCity(polity, city);
if (!connection.connected || connection.distance > (cfg.maxTargetDistance ?? 48)) continue;
2026-09-16 13:51:43 +09:00
const tile = this.world.idx(city.x, city.y);
if (!this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 8, allowIndependentCity: true })) continue;
2026-05-17 21:08:03 +09:00
const cityInfluence = Math.max(1, this.cityInfluence(city));
const proximity = 1 / (1 + connection.distance * 0.06);
const cityStrength = clamp(city.strength || 1, 0.2, 8) * 0.12;
const score = Math.sqrt(city.population || 1) * 0.12 + (city.tradeValue || 0) * 0.55 + proximity * 1.4 - connection.distance * 0.012 - cityStrength + this.polityPower(polity) / Math.max(1, cityInfluence) * 0.18;
targets.push({
2026-09-16 13:51:43 +09:00
targetTile: tile,
2026-05-17 21:08:03 +09:00
targetCityId: city.id,
connection,
weight: this.noisyTargetWeight(score)
});
}
return targets;
}
borderWarCampaignTargets(polity) {
const cfg = SimConfig.campaign || {};
const targets = [];
for (const other of this.polities) {
if (other.id === polity.id || !this.canBeWarTarget(other.id)) continue;
const distance = this.polityDistance(polity, other);
2026-05-18 17:13:18 +09:00
if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48)) continue;
2026-05-17 21:08:03 +09:00
const border = this.nearestBorderCampaignTile(polity, other);
const city = this.borderWarTargetCity(polity, other);
2026-09-16 13:51:43 +09:00
if (city && !this.isBorderWarCityTarget(polity, other, city, border.tile)) continue;
if (!city && (border.tile == null || !this.isAdjacentToPolity(border.tile, polity.id))) continue;
2026-05-17 21:08:03 +09:00
const defenderPower = Math.max(1, this.polityPower(other));
2026-05-18 17:13:18 +09:00
const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.18 + (city.tradeValue || 0) * 0.5 : 0.25;
2026-05-17 21:08:03 +09:00
const contested = border.tile != null && this.world.contested[border.tile] ? 0.75 : 0;
const borderValue = this.borderFriction(polity, other, distance) + contested + targetCityValue;
const riskAppetite = this.rng.next() < 0.10 ? this.rng.range(0.4, 1.1) : 0;
2026-05-18 17:13:18 +09:00
const score = borderValue - defenderPower / 90 - distance * 0.018 + this.polityPower(polity) / 120 + riskAppetite;
2026-05-17 21:08:03 +09:00
targets.push({
targetTile: city ? this.world.idx(city.x, city.y) : border.tile,
targetCityId: city?.id ?? null,
defenderPolityId: other.id,
2026-05-18 17:13:18 +09:00
weight: this.noisyTargetWeight(score)
2026-05-17 21:08:03 +09:00
});
}
return targets;
}
sampleTileCampaignTargets(polity, scorer) {
const w = this.world;
const cfg = SimConfig.campaign || {};
const targets = [];
const seen = new Set();
const cities = this.getPolityCities(polity);
const maxDistance = cfg.maxTargetDistance ?? 48;
const tries = clamp(80 + cities.length * 22, 90, 260);
for (let n = 0; n < tries; n++) {
const source = cities[this.rng.int(cities.length)];
if (!source) break;
const radius = Math.ceil(maxDistance);
const x = clamp(source.x + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1);
const y = clamp(source.y + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1);
const tile = w.idx(x, y);
if (seen.has(tile)) continue;
seen.add(tile);
const connection = this.nearestPolityTileConnection(polity, tile);
if (!connection.source || connection.distance > maxDistance) continue;
const base = scorer(tile, connection);
if (!base) continue;
targets.push({
...base,
connection,
weight: Math.max(0, base.weight || 0) / (1 + connection.distance * 0.035)
});
}
return targets;
}
2026-09-16 13:51:43 +09:00
isValidExpansionTarget(polity, tile, options = {}) {
const w = this.world;
if (!polity || tile == null || w.terrain[tile] === Terrain.WATER) return false;
const owner = w.polity[tile];
if (options.requireUnowned && owner >= 0) return false;
if (owner >= 0 && owner !== polity.id && !options.allowEnemy) return false;
const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
if (city && city.polityId !== null && city.polityId !== polity.id && !options.allowEnemy) return false;
if (city && city.polityId === null && !options.allowIndependentCity && !options.requireUnowned) return false;
if (owner === polity.id && options.allowOwned) return true;
const maxPath = options.maxNeutralPath ?? 6;
if (this.expansionConnectionDistance(polity, tile, maxPath) <= maxPath) return true;
if (w.tradeRoute[tile]) {
const connection = this.nearestPolityTileConnection(polity, tile);
return !!connection.source && connection.distance <= (SimConfig.campaign?.maxTargetDistance ?? 48) * 0.6;
}
this.territoryDebug.invalidCampaignTargetsRejected++;
return false;
}
expansionConnectionDistance(polity, tile, maxPath = 6) {
const w = this.world;
if (w.polity[tile] === polity.id) return 0;
const visited = this.visitStamp;
const marker = this.nextVisitMarker();
const queue = [{ tile, distance: 0 }];
visited[tile] = marker;
for (let q = 0; q < queue.length; q++) {
const current = queue[q];
if (current.distance >= maxPath) continue;
let found = Infinity;
this.forCardinalNeighbors(current.tile, n => {
if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return;
if (w.polity[n] === polity.id) {
found = current.distance + 1;
return false;
}
if (w.polity[n] >= 0 && w.polity[n] !== polity.id) return;
visited[n] = marker;
queue.push({ tile: n, distance: current.distance + 1 });
});
if (found < Infinity) return found;
}
return Infinity;
}
isAdjacentToPolity(tile, polityId) {
const w = this.world;
let adjacent = false;
this.forCardinalNeighbors(tile, n => {
if (w.polity[n] !== polityId) return;
adjacent = true;
return false;
});
return adjacent;
}
isBorderWarCityTarget(attacker, defender, city, fallbackBorderTile = null) {
const tile = this.world.idx(city.x, city.y);
if (this.isAdjacentToPolity(tile, attacker.id)) return true;
if (fallbackBorderTile != null) {
const distance = Math.abs(city.x - (fallbackBorderTile % this.world.size)) + Math.abs(city.y - Math.floor(fallbackBorderTile / this.world.size));
if (distance <= 10) return true;
}
return this.expansionConnectionDistance(attacker, tile, 8) <= 8 && this.isValidExpansionTarget(attacker, tile, { allowEnemy: true, maxNeutralPath: 8 });
}
2026-05-17 21:08:03 +09:00
nearestPolityTileConnection(polity, tile) {
const w = this.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let source = null;
let distance = Infinity;
for (const city of this.getPolityCities(polity)) {
let d = Math.abs(city.x - x) + Math.abs(city.y - y);
if (w.tradeRoute[tile]) d *= 0.72;
if (d < distance) {
source = city;
distance = d;
}
}
return { source, distance };
}
nearestBorderCampaignTile(attacker, defender) {
const w = this.world;
let best = { tile: this.world.idx(this.getPolityCities(defender)[0]?.x || 0, this.getPolityCities(defender)[0]?.y || 0), distance: Infinity };
for (let i = 0; i < w.count; i++) {
if (w.polity[i] !== defender.id || !this.isBorderTile(i)) continue;
const connection = this.nearestPolityTileConnection(attacker, i);
if (connection.distance < best.distance) best = { tile: i, distance: connection.distance };
}
return best;
}
borderWarTargetCity(attacker, defender) {
2026-05-18 17:13:18 +09:00
const maxDistance = SimConfig.campaign?.maxTargetDistance ?? 48;
2026-05-18 02:51:53 +09:00
let best = null;
2026-05-18 17:13:18 +09:00
let bestDistance = Infinity;
2026-05-18 02:51:53 +09:00
for (const city of this.getPolityCities(defender)) {
const connection = this.polityConnectionToCity(attacker, city);
2026-05-18 17:13:18 +09:00
if (connection.connected && connection.distance < bestDistance && connection.distance <= maxDistance) {
2026-05-18 02:51:53 +09:00
best = city;
2026-05-18 17:13:18 +09:00
bestDistance = connection.distance;
2026-05-17 21:08:03 +09:00
}
}
return best;
}
startCampaign(polity, target) {
const cfg = SimConfig.campaign || {};
2026-09-16 20:53:17 +09:00
if (target.type === "border_war") {
const defender = this.getPolityById(target.defenderPolityId);
if (!defender || (!this.getWarBetween(polity.id, defender.id) && !this.startWar(polity, defender))) return null;
}
2026-05-17 21:08:03 +09:00
const duration = years(Math.min(cfg.maxDurationYears ?? 25, (cfg.baseDurationYears ?? 8) + this.rng.int(Math.max(1, (cfg.maxDurationYears ?? 25) - (cfg.baseDurationYears ?? 8) + 1))));
const targetTile = target.targetTile ?? (target.targetCityId ? this.cityTile(this.getCityById(target.targetCityId)) : null);
if (targetTile == null) return null;
const campaign = {
id: this.nextCampaign++,
type: target.type,
2026-09-16 20:53:17 +09:00
annexationMode: target.type === "independent_city_annexation" ? this.independentAnnexationMode() : null,
2026-05-17 21:08:03 +09:00
attackerPolityId: polity.id,
defenderPolityId: target.defenderPolityId ?? null,
targetCityId: target.targetCityId ?? null,
targetTile,
targetEthnicity: target.targetEthnicity ?? null,
started: this.year,
2026-09-16 20:53:17 +09:00
expires: target.type === "border_war" ? Infinity : this.year + duration,
2026-05-17 21:08:03 +09:00
progress: 0,
strength: 0,
resistance: 0,
2026-05-18 17:13:18 +09:00
status: "active"
2026-05-17 21:08:03 +09:00
};
this.campaigns.push(campaign);
polity.lastCampaignYear = this.year;
polity.nextCampaignAllowedYear = this.year + years(this.rng.range(cfg.minCooldownYears ?? 12, cfg.maxCooldownYears ?? 45));
if (campaign.type === "border_war") {
const defender = this.getPolityById(campaign.defenderPolityId);
if (defender && !this.getWarBetween(polity.id, defender.id)) this.startWar(polity, defender);
this.addPolityEvent(polity.id, "borderWar", this.year, this.campaignEventData(campaign, "started"), 2);
if (defender) this.addPolityEvent(defender.id, "borderWar", this.year, this.campaignEventData(campaign, "defending"), 2);
} else if (campaign.type === "frontier_colonization") {
this.addPolityEvent(polity.id, "expansion", this.year, this.campaignEventData(campaign, "started"), 1);
}
this.addPolityEvent(polity.id, "campaign", this.year, this.campaignEventData(campaign, "started"), 1);
return campaign;
}
2026-09-16 20:53:17 +09:00
independentAnnexationMode() {
const peacefulShare = SimConfig.campaign?.independentCityPeacefulShare ?? 0.4;
return this.rng.next() < peacefulShare ? "peaceful" : "military";
}
2026-05-17 21:08:03 +09:00
cityTile(city) {
return city ? this.world.idx(city.x, city.y) : null;
}
updateCampaigns() {
for (const campaign of this.campaigns) {
if (campaign.status !== "active") continue;
2026-09-16 20:53:17 +09:00
const borderWar = campaign.type === "border_war";
if (this.year % (borderWar ? SimConfig.war.tickWeeks : years(1)) !== 0) continue;
2026-05-17 21:08:03 +09:00
const attacker = this.getPolityById(campaign.attackerPolityId);
if (!attacker) {
campaign.status = "failed";
continue;
}
const force = this.campaignAttackerForce(campaign, attacker);
const resistance = this.campaignResistance(campaign, attacker);
campaign.strength = force;
campaign.resistance = resistance;
const momentum = (force - resistance) / Math.max(1, force + resistance);
2026-09-16 20:53:17 +09:00
campaign.progress = clamp(campaign.progress + (borderWar ? 0.25 + momentum * 0.4 : 0.09 + momentum * 0.18) + this.rng.range(-0.08, 0.08), -0.35, 1.25);
2026-05-17 21:08:03 +09:00
attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(0.05, force * 0.018));
if (campaign.progress >= 1) this.resolveCampaignSuccess(campaign);
else if (campaign.progress <= -0.28) this.resolveCampaignFailure(campaign);
else if (this.year >= campaign.expires) {
if (campaign.progress > 0.72 && this.rng.next() < 0.45) this.resolveCampaignSuccess(campaign);
else this.resolveCampaignFailure(campaign, "expired");
}
}
this.campaigns = this.campaigns.filter(campaign => campaign.status === "active" || this.year - campaign.started < years(8));
}
campaignAttackerForce(campaign, attacker) {
const connection = this.nearestPolityTileConnection(attacker, campaign.targetTile);
const source = connection.source;
const nearbyStrength = source ? this.cityInfluence(source) * 0.32 : 1;
const routeAccess = this.world.tradeRoute[campaign.targetTile] || (source && this.hasDirectTradeConnection(source, this.getCityById(campaign.targetCityId))) ? 1.18 : 1;
return Math.max(0.1,
this.polityPower(attacker) * 0.42 *
clamp(attacker.cohesion ?? 0.6, 0.2, 1.2) *
clamp(attacker.charisma ?? 1, 0.5, 1.5) *
clamp((attacker.treasury || 0) / Math.max(8, this.getPolityCities(attacker).length * 5), 0.25, 1.6) *
routeAccess +
nearbyStrength
);
}
campaignResistance(campaign, attacker) {
const w = this.world;
const tile = campaign.targetTile;
const targetCity = this.getCityById(campaign.targetCityId);
const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null;
const connection = this.nearestPolityTileConnection(attacker, tile);
const terrain = w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1);
2026-09-16 20:53:17 +09:00
const population = targetCity?.population || w.population[tile] || 0;
2026-05-18 17:13:18 +09:00
const defenderPower = defender ? this.polityPower(defender) * 0.55 : 0;
2026-05-17 21:08:03 +09:00
const attackerEthnicity = this.dominantCityEthnicity(connection.source);
2026-05-18 17:13:18 +09:00
const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : w.dominantEthnicity[tile];
2026-05-17 21:08:03 +09:00
const mismatch = attackerEthnicity !== null && localEthnicity !== null && localEthnicity >= 0 && attackerEthnicity !== localEthnicity ? 1.22 : 0.92;
const foreignControl = w.polity[tile] >= 0 && w.polity[tile] !== attacker.id ? 1.35 : 1;
return Math.max(0.2,
2026-05-18 17:13:18 +09:00
(Math.sqrt(Math.max(1, population)) * 3.2 + terrain * 8 + connection.distance * 0.32 + defenderPower) *
2026-05-17 21:08:03 +09:00
mismatch *
foreignControl *
(1 + (w.cultureDiversity[tile] || 0) * 0.35)
);
}
resolveCampaignSuccess(campaign) {
campaign.status = "succeeded";
const attacker = this.getPolityById(campaign.attackerPolityId);
if (!attacker) return;
if (campaign.type === "independent_city_annexation") {
const city = this.getCityById(campaign.targetCityId);
2026-09-16 13:51:43 +09:00
const cityTile = city ? this.world.idx(city.x, city.y) : null;
const cityTileOwner = cityTile != null ? this.world.territoryOwner[cityTile] : -1;
if (city && city.polityId === null && (cityTileOwner < 0 || cityTileOwner === attacker.id)) {
2026-05-17 21:08:03 +09:00
const similarity = this.campaignEthnicSimilarity(attacker, campaign);
2026-09-16 20:53:17 +09:00
const peaceful = campaign.annexationMode === "peaceful";
const resistanceRatio = campaign.resistance / Math.max(1, campaign.strength + campaign.resistance);
if (!peaceful) {
this.applyCityWarDamage(city, clamp(0.025 + resistanceRatio * 0.06, 0.025, 0.10), "independentAnnexation");
}
2026-09-16 13:51:43 +09:00
this.transferCityToPolity(city, attacker, {
2026-09-16 20:53:17 +09:00
loyalty: peaceful
? clamp(0.58 + similarity * 0.22, 0.58, 0.82)
: clamp(0.20 + similarity * 0.22 - resistanceRatio * 0.12, 0.12, 0.48),
control: peaceful ? 0.58 : 0.46,
claim: peaceful ? 0.68 : 0.62,
reason: peaceful ? "peacefulAnnexation" : "militaryAnnexation"
2026-09-16 13:51:43 +09:00
});
2026-09-16 20:53:17 +09:00
campaign.affectedTiles = this.applyCampaignTerritory(attacker, cityTile, 5, peaceful ? 0.48 : 0.42, {
2026-09-16 13:51:43 +09:00
allowForeignTakeover: false,
2026-09-16 20:53:17 +09:00
reason: peaceful ? "peacefulAnnexation" : "militaryAnnexation"
2026-09-16 13:51:43 +09:00
}) || [];
2026-05-17 21:08:03 +09:00
}
2026-09-16 20:53:17 +09:00
this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, campaign.annexationMode || "military"), 2);
2026-05-17 21:08:03 +09:00
} else if (campaign.type === "border_war") {
const defender = this.getPolityById(campaign.defenderPolityId);
const city = this.getCityById(campaign.targetCityId);
if (city && defender && city.polityId === defender.id) {
2026-09-16 13:51:43 +09:00
this.applyCityWarDamage(city, clamp(0.04 + campaign.progress * 0.025, 0.03, 0.14), "campaign");
this.transferCityToPolity(city, attacker, {
loyalty: 0.22,
control: 0.58,
claim: 0.72,
allowForeignTakeover: true,
reason: "borderWar"
});
campaign.affectedTiles = this.transferWarTerritory(attacker, defender, this.world.idx(city.x, city.y), Math.max(9, SimConfig.campaign?.claimRadius ?? 8), {
strength: 0.62,
reason: "borderWar",
maxTiles: 95
}) || [];
2026-05-17 21:08:03 +09:00
} else {
2026-09-16 13:51:43 +09:00
campaign.affectedTiles = defender
? this.transferFrontierBelt(attacker, defender, campaign.targetTile, 4, 5, { maxTiles: 70, reason: "borderWar" }) || []
: this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48, { allowForeignTakeover: false, reason: "campaign" }) || [];
this.applyWarZoneTileDamage(campaign.targetTile, (SimConfig.war.warZoneTileLossRate ?? 0.006) * 1.5);
2026-05-17 21:08:03 +09:00
}
if (defender) {
2026-05-18 17:13:18 +09:00
defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.04, 0, 1);
2026-09-16 13:51:43 +09:00
defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.025, 0, 1);
2026-05-18 17:13:18 +09:00
defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * 0.04);
2026-09-16 13:51:43 +09:00
const war = this.getWarBetween(attacker.id, defender.id);
if (war) this.addWarScore(war, attacker.id, city ? 1.5 : 0.75);
2026-05-17 21:08:03 +09:00
}
this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2);
} else if (campaign.type === "nonstate_subjugation") {
2026-09-16 13:51:43 +09:00
this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.56, {
allowForeignTakeover: false,
reason: "subjugation"
});
2026-05-17 21:08:03 +09:00
this.applySubjugationEffects(attacker, campaign);
this.addPolityEvent(attacker.id, "subjugation", this.year, this.campaignEventData(campaign, "succeeded"), 1);
} else {
2026-09-16 13:51:43 +09:00
this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.46, {
allowForeignTakeover: false,
reason: "frontier"
});
2026-05-17 21:08:03 +09:00
this.seedFrontierPopulation(attacker, campaign.targetTile);
this.maybeCreateFrontierSettlement(attacker, campaign.targetTile);
this.addPolityEvent(attacker.id, "expansion", this.year, this.campaignEventData(campaign, "succeeded"), 1);
this.addPolityEvent(attacker.id, "colonization", this.year, this.campaignEventData(campaign, "succeeded"), 1);
}
this.recordCampaign(attacker, campaign, campaign.status);
this.syncTilePopulationCulture();
2026-09-16 13:51:43 +09:00
this.recomputeTerritories({ allowOwnershipChanges: false, reason: "campaign" });
this.reconcileCityTerritoryOwnership();
2026-05-17 21:08:03 +09:00
this.campaignPressureOnTerritories();
}
resolveCampaignFailure(campaign, reason = "failed") {
campaign.status = reason;
const attacker = this.getPolityById(campaign.attackerPolityId);
if (!attacker) return;
attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(1, campaign.strength * 0.04));
attacker.cohesion = clamp((attacker.cohesion ?? 0.6) - 0.025, 0, 1);
attacker.legitimacy = clamp((attacker.legitimacy ?? 0.7) - 0.018, 0, 1);
if (campaign.type === "nonstate_subjugation") {
const w = this.world;
w.cultureDiversity[campaign.targetTile] = clamp((w.cultureDiversity[campaign.targetTile] || 0) + 0.10, 0, 1);
}
const war = campaign.defenderPolityId != null ? this.getWarBetween(attacker.id, campaign.defenderPolityId) : null;
if (war) {
if (war.aPolityId === attacker.id) war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1);
else war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1);
2026-09-16 13:51:43 +09:00
const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null;
if (defender) this.addWarScore(war, defender.id, 0.45 + clamp(campaign.resistance / Math.max(1, campaign.strength), 0, 1.2));
2026-05-17 21:08:03 +09:00
}
this.addPolityEvent(attacker.id, "failedCampaign", this.year, this.campaignEventData(campaign, reason), 1);
2026-09-16 20:53:17 +09:00
const peacefulAnnexation = campaign.type === "independent_city_annexation" && campaign.annexationMode === "peaceful";
if (!peacefulAnnexation) {
2026-09-16 13:51:43 +09:00
this.applyPolityWarExhaustionPopulationLoss(attacker, 0.45, clamp(campaign.resistance / Math.max(1, campaign.strength), 0.4, 1.4));
this.applyWarZoneTileDamage(campaign.targetTile, SimConfig.war.warZoneTileLossRate ?? 0.006);
2026-09-16 20:53:17 +09:00
}
2026-05-17 21:08:03 +09:00
this.recordCampaign(attacker, campaign, campaign.status);
}
campaignEthnicSimilarity(polity, campaign) {
const source = this.nearestPolityTileConnection(polity, campaign.targetTile).source;
const targetCity = this.getCityById(campaign.targetCityId);
const a = this.dominantCityEthnicity(source);
2026-05-18 17:13:18 +09:00
const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.dominantEthnicity[campaign.targetTile];
2026-05-17 21:08:03 +09:00
if (a === null || b === null || b < 0) return 0.45;
return a === b ? 1 : 0.25;
}
2026-09-16 13:51:43 +09:00
transferTerritoryTiles(fromPolityId, toPolityId, tiles, options = {}) {
const w = this.world;
const changed = [];
const toPolity = this.getPolityById(toPolityId);
const clearing = toPolityId == null || toPolityId < 0;
if (!toPolity && !clearing) return changed;
for (const tile of tiles) {
if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) continue;
const owner = w.territoryOwner[tile];
2026-09-16 20:53:17 +09:00
if (
fromPolityId != null &&
owner !== fromPolityId &&
owner !== toPolityId &&
!(options.allowNeutralWithFrom && owner < 0)
) continue;
2026-09-16 13:51:43 +09:00
if (!clearing && owner >= 0 && owner !== toPolityId && !options.allowForeignTakeover) continue;
if (owner === toPolityId || (clearing && owner < 0)) continue;
this.setTileOwner(tile, toPolityId, {
control: options.control ?? 0.32,
claim: options.claim ?? 0.45,
contested: options.contested ?? false
});
if (clearing) {
this.territorialClaims.delete(tile);
} else {
this.territorialClaims.set(tile, {
polityId: toPolityId,
control: options.control ?? 0.32,
claim: options.claim ?? 0.45,
contested: !!options.contested,
reason: options.reason ?? "transfer",
year: this.year
});
}
changed.push(tile);
}
if (changed.length) {
this.markContestedFront(toPolity, fromPolityId != null ? this.getPolityById(fromPolityId) : null, changed, options.intensity ?? 0.45);
if (fromPolityId != null && fromPolityId !== toPolityId) {
const defender = this.getPolityById(fromPolityId);
if (defender) {
defender.cohesion = clamp((defender.cohesion ?? 0.6) - Math.min(0.08, changed.length * 0.0008), 0, 1);
defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - Math.min(0.06, changed.length * 0.0006), 0, 1);
}
}
this.recomputeTerritories({ allowOwnershipChanges: false, reason: options.reason ?? "transfer" });
}
return changed;
}
claimPolityCoreTerritory(polity, city, radius = 4, reason = "founding") {
if (!polity || !city) return [];
const w = this.world;
const tiles = [];
2026-09-16 20:53:17 +09:00
for (const { dx, dy } of this.getRadiusOffsets(Math.ceil(radius * Math.SQRT2))) {
const distance = Math.hypot(dx, dy);
if (distance > radius) continue;
2026-09-16 13:51:43 +09:00
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
if (w.territoryOwner[tile] >= 0 && w.territoryOwner[tile] !== polity.id) continue;
const otherCity = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
if (otherCity && otherCity.polityId !== null && otherCity.polityId !== polity.id) continue;
const falloff = 1 - distance / (radius + 1);
if (falloff <= 0) continue;
tiles.push(tile);
}
return this.transferTerritoryTiles(null, polity.id, tiles, {
control: 0.42,
claim: 0.58,
reason
});
}
2026-09-16 20:53:17 +09:00
isConquestTransferReason(reason) {
return reason === "warCapture" || reason === "borderWar" || reason === "militaryAnnexation" || reason === "settlement" || reason === "nomadConquest";
}
protectCityFromRebellion(city, graceYears = SimConfig.rebellion?.conquestGraceYears ?? 20) {
if (!city) return;
city.rebellionProtectedUntil = Math.max(city.rebellionProtectedUntil || 0, this.year + years(Math.max(0, graceYears)));
}
isCityRebellionProtected(city) {
return !!city && (city.rebellionProtectedUntil || 0) > this.year;
}
2026-09-16 13:51:43 +09:00
transferCityToPolity(city, toPolity, options = {}) {
if (!city || !toPolity) return false;
const fromPolityId = city.polityId;
if (fromPolityId != null && fromPolityId !== toPolity.id) this.removeCityFromPolity(city);
if (city.polityId !== toPolity.id) this.addCityToPolity(city, toPolity, options.loyalty ?? 0.28);
const tile = this.world.idx(city.x, city.y);
this.transferTerritoryTiles(options.fromPolityId ?? fromPolityId, toPolity.id, [tile], {
allowForeignTakeover: !!options.allowForeignTakeover,
control: options.control ?? 0.58,
claim: options.claim ?? 0.70,
reason: options.reason ?? "cityTransfer"
});
2026-09-17 22:32:25 +09:00
if (this.isConquestTransferReason(options.reason)) {
this.protectCityFromRebellion(city, options.rebellionProtectionYears);
// City occupation also disrupts the surrounding rural population. Keep this
// separate from ordinary battle-zone damage so every conquest-style transfer
// receives the same occupation shock exactly once.
if (fromPolityId !== toPolity.id) this.applyOccupationPopulationShock(tile);
}
2026-09-16 13:51:43 +09:00
return true;
}
releaseCityTerritory(city, fromPolityId, radius = 4, reason = "fragmentation") {
if (!city || fromPolityId == null) return [];
const w = this.world;
const tiles = [];
for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) {
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER || w.territoryOwner[tile] !== fromPolityId) continue;
const cityHere = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
if (cityHere && cityHere.id !== city.id && cityHere.polityId === fromPolityId) continue;
const falloff = 1 - distance / (radius + 1);
if (falloff > 0) tiles.push(tile);
}
return this.transferTerritoryTiles(fromPolityId, -1, tiles, { reason, intensity: 0.55 });
}
releasePolityTerritory(polityId, reason = "collapse") {
if (polityId == null) return [];
const w = this.world;
const tiles = [];
for (let i = 0; i < w.count; i++) {
if (w.territoryOwner[i] === polityId) tiles.push(i);
}
return this.transferTerritoryTiles(polityId, -1, tiles, { reason, intensity: 0.65 });
}
markContestedFront(attacker, defender, tiles, intensity = 0.45) {
const w = this.world;
const attackerId = typeof attacker === "object" ? attacker?.id : attacker;
const defenderId = typeof defender === "object" ? defender?.id : defender;
for (const tile of tiles) {
this.forCardinalNeighbors(tile, n => {
if (w.terrain[n] === Terrain.WATER) return;
if (defenderId != null && w.territoryOwner[n] === defenderId) {
w.contested[n] = 1;
w.claim[n] = Math.max(w.claim[n], clamp(intensity, 0.18, 0.85));
}
if (attackerId != null && w.territoryOwner[n] === attackerId) {
w.control[n] = Math.max(w.control[n], clamp(0.24 + intensity * 0.35, 0.24, 0.75));
}
});
}
}
transferWarTerritory(attacker, defender, centerTile, radius, options = {}) {
if (!attacker || !defender || centerTile == null) return [];
return this.applyCampaignTerritory(attacker, centerTile, radius, options.strength ?? 0.55, {
allowForeignTakeover: true,
fromPolityId: defender.id,
reason: options.reason ?? "war",
preferFront: true,
maxTiles: options.maxTiles ?? null
});
}
transferFrontierBelt(attacker, defender, frontTile, width = 3, depth = 3, options = {}) {
const w = this.world;
if (!attacker || !defender || frontTile == null) return [];
const queue = [{ tile: frontTile, distance: 0 }];
const visited = this.visitStamp;
const marker = this.nextVisitMarker();
visited[frontTile] = marker;
const tiles = [];
const maxTiles = options.maxTiles ?? clamp(width * depth * 7, 10, 140);
for (let q = 0; q < queue.length && tiles.length < maxTiles; q++) {
const { tile, distance } = queue[q];
if (distance > width + depth) continue;
if (w.terrain[tile] !== Terrain.WATER && w.territoryOwner[tile] === defender.id) tiles.push(tile);
this.forCardinalNeighbors(tile, n => {
if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return;
const owner = w.territoryOwner[n];
if (owner !== defender.id && owner !== attacker.id) return;
visited[n] = marker;
queue.push({ tile: n, distance: distance + 1 });
});
}
return this.transferTerritoryTiles(defender.id, attacker.id, tiles, {
allowForeignTakeover: true,
control: options.control ?? 0.30,
claim: options.claim ?? 0.48,
contested: true,
reason: options.reason ?? "frontAdvance",
intensity: options.intensity ?? 0.55
});
}
applyCampaignTerritory(polity, centerTile, radius, strength, options = {}) {
2026-05-17 21:08:03 +09:00
const w = this.world;
const cx = centerTile % w.size;
const cy = Math.floor(centerTile / w.size);
2026-09-16 13:51:43 +09:00
const centerCity = w.city[centerTile] >= 0 ? this.getCityById(w.city[centerTile]) : null;
const capturedCitySeed = centerCity?.polityId === polity.id;
const allowForeignTakeover = !!options.allowForeignTakeover;
const fromPolityId = options.fromPolityId ?? null;
if (!capturedCitySeed && !allowForeignTakeover && !this.isValidExpansionTarget(polity, centerTile, { allowOwned: true, maxNeutralPath: 8 })) {
this.territoryDebug.invalidCampaignTargetsRejected++;
return false;
}
const seeds = capturedCitySeed || allowForeignTakeover ? [centerTile] : this.connectedExpansionSeeds(polity, centerTile, 8);
if (!seeds.length) {
this.territoryDebug.invalidCampaignTargetsRejected++;
return false;
}
const visited = this.visitStamp;
const marker = this.nextVisitMarker();
2026-09-16 20:53:17 +09:00
const queue = seeds.map(tile => ({ tile, distance: Math.hypot((tile % w.size) - cx, Math.floor(tile / w.size) - cy) }));
2026-09-16 13:51:43 +09:00
for (const seed of seeds) visited[seed] = marker;
const candidates = [];
const maxTiles = options.maxTiles ?? Infinity;
for (let q = 0; q < queue.length; q++) {
const { tile, distance } = queue[q];
2026-05-17 21:08:03 +09:00
if (distance > radius) continue;
if (w.terrain[tile] === Terrain.WATER) continue;
2026-09-16 13:51:43 +09:00
const tileCity = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null;
if (tileCity && tileCity.polityId !== null && tileCity.polityId !== polity.id && !allowForeignTakeover) continue;
const owner = w.territoryOwner[tile];
const canTake =
owner === polity.id ||
(allowForeignTakeover
2026-09-16 20:53:17 +09:00
? (fromPolityId == null ? owner < 0 || owner !== polity.id : owner < 0 || owner === fromPolityId)
2026-09-16 13:51:43 +09:00
: owner < 0);
if (!canTake) {
if (owner >= 0 && owner !== polity.id) w.contested[tile] = 1;
continue;
}
2026-05-17 21:08:03 +09:00
const local = strength * (1 - distance / (radius + 1));
2026-09-16 13:51:43 +09:00
candidates.push({ tile, distance, local });
if (candidates.length >= maxTiles) break;
this.forCardinalNeighbors(tile, (n, tx, ty) => {
if (visited[n] === marker) return;
2026-09-16 20:53:17 +09:00
const nd = Math.hypot(tx - cx, ty - cy);
2026-09-16 13:51:43 +09:00
if (nd > radius || w.terrain[n] === Terrain.WATER) return;
const neighborOwner = w.territoryOwner[n];
if (neighborOwner >= 0 && neighborOwner !== polity.id && !allowForeignTakeover) {
w.contested[n] = 1;
return;
}
if (allowForeignTakeover && fromPolityId != null && neighborOwner >= 0 && neighborOwner !== polity.id && neighborOwner !== fromPolityId) return;
visited[n] = marker;
queue.push({ tile: n, distance: nd });
2026-05-17 21:08:03 +09:00
});
}
2026-09-16 13:51:43 +09:00
const ordered = candidates
.sort((a, b) => a.distance - b.distance)
.slice(0, Number.isFinite(maxTiles) ? maxTiles : candidates.length);
return this.transferTerritoryTiles(
fromPolityId,
polity.id,
ordered.map(entry => entry.tile),
{
allowForeignTakeover,
2026-09-16 20:53:17 +09:00
allowNeutralWithFrom: allowForeignTakeover && fromPolityId != null,
2026-09-16 13:51:43 +09:00
control: Math.max(0.18, strength * 0.65),
claim: Math.max(0.22, strength),
contested: false,
reason: options.reason ?? "campaign",
intensity: strength
}
);
}
connectedExpansionSeeds(polity, tile, maxPath = 8) {
const w = this.world;
if (w.polity[tile] === polity.id) return [tile];
const visited = this.visitStamp;
const marker = this.nextVisitMarker();
const queue = [{ tile, distance: 0 }];
const seeds = [];
visited[tile] = marker;
for (let q = 0; q < queue.length; q++) {
const current = queue[q];
if (current.distance >= maxPath) continue;
this.forCardinalNeighbors(current.tile, n => {
if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return;
if (w.polity[n] === polity.id) {
seeds.push(n);
return;
}
if (w.polity[n] >= 0 && w.polity[n] !== polity.id) return;
visited[n] = marker;
queue.push({ tile: n, distance: current.distance + 1 });
});
2026-05-17 21:08:03 +09:00
}
2026-09-16 13:51:43 +09:00
return seeds;
2026-05-17 21:08:03 +09:00
}
applySubjugationEffects(polity, campaign) {
2026-05-18 17:13:18 +09:00
const localEthnicity = campaign.targetEthnicity ?? this.world.dominantEthnicity[campaign.targetTile];
2026-05-17 21:08:03 +09:00
if (localEthnicity == null || localEthnicity < 0) return;
const sourceEthnicity = this.dominantCityEthnicity(this.nearestPolityTileConnection(polity, campaign.targetTile).source);
if (sourceEthnicity === localEthnicity) return;
const x = campaign.targetTile % this.world.size;
const y = Math.floor(campaign.targetTile / this.world.size);
for (const city of this.getCitiesNear(x, y, 12)) {
if (city.polityId === polity.id) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.05, 0, 1);
}
}
seedFrontierPopulation(polity, tile) {
const source = this.nearestPolityTileConnection(polity, tile).source;
2026-05-18 17:13:18 +09:00
const ethnicity = this.dominantCityEthnicity(source) || this.world.dominantEthnicity[tile];
2026-05-17 21:08:03 +09:00
if (!source || ethnicity == null || ethnicity < 0) return;
const migrants = Math.max(1, Math.floor(Math.min(source.population * 0.018, 14)));
source.population = Math.max(1, source.population - migrants);
removeFromComposition(source.ethnicityComposition, migrants);
this.world.population[tile] += migrants;
2026-09-16 13:51:43 +09:00
this.markPopulationTile(tile);
2026-05-17 21:08:03 +09:00
let culture = this.tileEthnicMix.get(tile);
if (!culture) {
culture = new Map();
this.tileEthnicMix.set(tile, culture);
}
culture.set(ethnicity, (culture.get(ethnicity) || 0) + migrants);
2026-09-16 13:51:43 +09:00
this.updateCultureTile(tile);
2026-05-17 21:08:03 +09:00
}
maybeCreateFrontierSettlement(polity, tile) {
const w = this.world;
if (this.cities.length >= SimConfig.city.maxCities || w.city[tile] >= 0) return null;
if ((w.population[tile] || 0) < 8 || w.fertility[tile] + w.resource[tile] * 0.025 < 0.8) return null;
const target = {
type: "frontier_colonization",
tile,
x: tile % w.size,
y: Math.floor(tile / w.size),
population: w.population[tile] || 0,
connection: this.nearestPolityTileConnection(polity, tile)
};
return this.foundCampaignOutpost(polity, target);
}
campaignEventData(campaign, outcome) {
return {
campaignId: campaign.id,
campaignType: campaign.type,
2026-09-16 20:53:17 +09:00
annexationMode: campaign.annexationMode ?? null,
2026-05-17 21:08:03 +09:00
outcome,
targetCityId: campaign.targetCityId ?? null,
targetPolityId: campaign.defenderPolityId ?? null,
targetTile: campaign.targetTile,
progress: Number((campaign.progress || 0).toFixed(2))
};
}
recordCampaign(polity, campaign, outcome) {
const data = this.campaignEventData(campaign, outcome);
this.campaignHistory.push({ year: this.year, polityId: polity.id, ...data });
while (this.campaignHistory.length > 160) this.campaignHistory.shift();
}
foundCampaignOutpost(polity, target) {
const w = this.world;
const targetTile = target?.targetTile ?? target?.tile;
if (this.cities.length >= SimConfig.city.maxCities || !target || targetTile == null) return null;
const source = target.connection?.source || this.getCityById(polity.centerCityId);
if (!source) return null;
const isFrontier = target.type === "frontier_colonization" || target.type === "frontier";
const cost = isFrontier ? 5.5 : 8.5;
if ((polity.treasury || 0) < cost && source.storedResources < cost * 1.4) return null;
const treasuryPaid = Math.min(polity.treasury || 0, cost);
polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryPaid);
source.storedResources = Math.max(0, source.storedResources - Math.max(0, cost - treasuryPaid) * 0.5);
const culture = this.tileCultures.get(targetTile);
const composition = new Map();
let regionalTotal = 0;
if (culture?.size) {
for (const [id, count] of culture) regionalTotal += count;
const basePopulation = clamp(Math.round((target.population || 0) * 0.55), 6, 58);
for (const [id, count] of culture) {
const share = regionalTotal > 0 ? count / regionalTotal : 0;
if (share > 0) composition.set(id, Math.max(1, Math.round(basePopulation * share)));
}
}
if (!composition.size) {
2026-05-18 17:13:18 +09:00
const regionalEthnicity = this.world.dominantEthnicity[targetTile];
2026-05-17 21:08:03 +09:00
const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1);
composition.set(sourceEthnicity, isFrontier ? 12 : 18);
}
2026-09-16 20:53:17 +09:00
const populationScale = SimConfig.city.populationScale;
const seedPopulation = Math.max(12, Math.round(compositionTotal(composition) * (isFrontier ? 1.2 : 1.6))) * populationScale;
for (const [id, count] of composition) composition.set(id, count * populationScale);
2026-05-17 21:08:03 +09:00
const city = {
id: this.nextCity++,
x: target.x ?? targetTile % w.size,
y: target.y ?? Math.floor(targetTile / w.size),
population: seedPopulation,
2026-09-16 20:53:17 +09:00
storedResources: (18 + (w.resource[targetTile] || 0) * 0.25) * populationScale,
2026-05-17 21:08:03 +09:00
ethnicityComposition: composition,
pheromoneOutput: 0,
agriculturalRadius: 2,
tradeLinks: new Set(),
activeVisitors: 0,
age: 0,
peakPopulation: seedPopulation,
strength: isFrontier ? 1.8 : 2.4,
sedentaryCulture: isFrontier ? 0.42 : 0.55,
knowledge: { ...source.knowledge },
supplyStress: 0,
polityId: null,
loyalty: isFrontier ? 0.52 : 0.32,
receivedAid: false,
tradeValue: 0,
2026-09-16 20:53:17 +09:00
tradeReach: 0,
rebellionProtectedUntil: 0
2026-05-17 21:08:03 +09:00
};
this.cities.push(city);
this.cityById.set(city.id, city);
const bucketKey = this.cityBucketKey(city.x, city.y);
if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
this.cityBuckets.get(bucketKey).push(city);
2026-09-16 13:51:43 +09:00
this.transferCityToPolity(city, polity, {
loyalty: city.loyalty,
control: 0.52,
claim: 0.66,
reason: "frontierSettlement"
});
this.claimPolityCoreTerritory(polity, city, 3, "frontierSettlement");
2026-05-17 21:08:03 +09:00
w.city[targetTile] = city.id;
this.removeEthnicPopulationProportionally(targetTile, Math.min(seedPopulation, (w.population[targetTile] || 0) * 0.65));
this.projectCityPopulationToTiles(city);
this.syncTilePopulationCulture();
return city;
}
2026-05-13 20:21:58 +09:00
maybeStartWars() {
2026-09-16 20:53:17 +09:00
if (this.year % SimConfig.war.tickWeeks !== 0) return;
let actions = 0;
let warSlots = SimConfig.war.maxActiveWars - this.wars.filter(war => war.ended === null).length;
const maxActions = SimConfig.war.maxStartsPerTick;
2026-05-13 20:21:58 +09:00
for (const a of this.polities) {
2026-09-16 20:53:17 +09:00
if (actions >= maxActions) break;
if (this.year < (a.nextWarAllowedWeek ?? 0)) continue;
2026-05-13 20:21:58 +09:00
if (this.isPolityAtWar(a.id)) continue;
const aPower = this.polityPower(a);
if (aPower <= 0) continue;
let started = false;
for (const b of this.polities) {
if (started) break;
2026-05-14 14:38:34 +09:00
if (a.id === b.id) continue;
2026-09-16 20:53:17 +09:00
if (this.year < (b.nextWarAllowedWeek ?? 0)) continue;
2026-05-14 14:38:34 +09:00
if (!this.canBeWarTarget(b.id)) continue;
2026-05-13 20:21:58 +09:00
if (this.getWarBetween(a.id, b.id)) continue;
const distance = this.polityDistance(a, b);
if (distance > 46) continue;
const bPower = this.polityPower(b);
if (bPower <= 0) continue;
const larger = Math.max(aPower, bPower);
const smaller = Math.min(aPower, bPower);
const advantage = larger / Math.max(1, smaller);
const proximity = clamp((46 - distance) / 46, 0, 1);
const aInstability = this.polityInstability ? this.polityInstability(a) : 0;
const bInstability = this.polityInstability ? this.polityInstability(b) : 0;
const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0;
const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0;
const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0;
const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0;
2026-05-14 14:38:34 +09:00
const aLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(a) : 0.5;
const bLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5;
2026-05-13 20:21:58 +09:00
const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1);
const weakSideInstability = aPower > bPower ? bInstability : aInstability;
const weakSideAge = aPower > bPower ? bAge : aAge;
const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension;
2026-05-14 14:38:34 +09:00
const weakSideLoyalty = aPower > bPower ? bLoyalty : aLoyalty;
const lowLoyaltyPressure = clamp(1 - Math.min(aLoyalty, bLoyalty), 0, 1);
const weakSideLowLoyalty = clamp(1 - weakSideLoyalty, 0, 1);
const generalInstability = Math.max(aInstability, bInstability) * 0.035;
const targetWarCount = this.warCountForPolity(b.id);
const targetLowLoyalty = clamp(1 - bLoyalty, 0, 1);
const attackerAdvantage = clamp((aPower / Math.max(1, bPower) - 1) / 2.25, 0, 1);
const opportunisticPressure = targetWarCount > 0
? clamp(targetWarCount / 3, 0, 1) * (
0.010 +
bInstability * 0.018 +
targetLowLoyalty * 0.018 +
attackerAdvantage * 0.020
)
: 0;
2026-05-13 20:21:58 +09:00
const borderFriction = this.borderFriction(a, b, distance);
const chance =
2026-05-14 14:38:34 +09:00
0.001 +
proximity * 0.007 +
asymmetryPressure * 0.008 +
weakSideInstability * 0.018 +
weakSideAge * 0.010 +
weakSideOverextension * 0.010 +
borderFriction * 0.008 +
generalInstability +
lowLoyaltyPressure * 0.018 +
weakSideLowLoyalty * 0.028 +
opportunisticPressure;
2026-09-16 20:53:17 +09:00
if (this.rng.next() < clamp(chance * SimConfig.war.startChanceMultiplier, 0, 0.35)) {
if (warSlots > 0) {
started = !!this.startWar(a, b);
if (started) warSlots--;
}
if (started) actions++;
2026-05-13 20:21:58 +09:00
}
}
}
}
borderFriction(a, b, distance) {
if (!Number.isFinite(distance)) return 0;
const proximity = clamp((34 - distance) / 34, 0, 1);
const aCities = this.getPolityCities(a).length;
const bCities = this.getPolityCities(b).length;
const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1);
return proximity * (0.35 + sizePressure * 0.65);
}
updateWars() {
2026-09-16 20:53:17 +09:00
if (this.year % SimConfig.war.tickWeeks !== 0) return;
this.warLogisticsCache = new Map();
2026-05-13 20:21:58 +09:00
for (const war of this.wars) {
if (war.ended !== null) continue;
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
this.endWar(war);
continue;
}
this.applyWarPressure(war, a, b);
this.applyWarPressure(war, b, a);
this.applyWarExhaustion(war, a, b);
this.maybeEndWar(war);
}
this.wars = this.wars.filter(w => w.ended === null);
}
applyWarPressure(war, attacker, defender) {
const attackerPower = this.polityPower(attacker);
const defenderPower = this.polityPower(defender);
2026-09-16 13:51:43 +09:00
const selected = this.bestWarPressureTarget(attacker, defender, war, attackerPower, defenderPower);
if (!selected) return;
2026-05-13 20:21:58 +09:00
const pressure = this.warAbsorptionPressure(
attacker,
defender,
selected.city,
selected.connection.distance,
attackerPower,
2026-09-16 20:53:17 +09:00
defenderPower,
selected.connection.logisticsFactor
2026-05-13 20:21:58 +09:00
);
2026-09-16 13:51:43 +09:00
const key = `${attacker.id}:${selected.city.id}`;
2026-09-16 20:53:17 +09:00
const memory = (war.cityPressureById?.get(key) || 0) * (SimConfig.war.pressureMemoryDecay ?? 0.88) + pressure * (war.intensity || 0.75) * 1.6;
2026-09-16 13:51:43 +09:00
war.cityPressureById?.set(key, memory);
const captureThreshold = war.goal === "collapse_exploitation" ? 1.55 : war.goal === "city_conquest" ? 1.75 : war.goal === "capital_pressure" ? 1.85 : 2.15;
2026-09-17 22:32:25 +09:00
const baseCaptureChance = clamp((memory - captureThreshold * 0.65 + pressure * 0.35) * 0.45, 0.05, 0.85);
const chance = clamp(baseCaptureChance * (SimConfig.war.captureChanceMultiplier ?? 1), 0, 0.85);
2026-09-16 13:51:43 +09:00
const damageRate = clamp(
2026-09-16 20:53:17 +09:00
(SimConfig.war.cityDamageMinRate ?? 0.003) + pressure * 0.030 + memory * 0.018,
2026-09-16 13:51:43 +09:00
SimConfig.war.cityDamageMinRate ?? 0.003,
SimConfig.war.cityDamageMaxRate ?? 0.08
);
2026-09-16 20:53:17 +09:00
if (memory > captureThreshold * 0.65 && pressure > 0.55 && this.rng.next() < chance) {
2026-05-13 20:21:58 +09:00
this.captureCityInWar(selected.city, attacker, defender, war, pressure);
} else {
2026-09-16 13:51:43 +09:00
this.applyCityWarDamage(selected.city, damageRate, "warPressure");
this.applyWarZoneTileDamage(this.world.idx(selected.city.x, selected.city.y), SimConfig.war.warZoneTileLossRate ?? 0.006);
selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.018 - memory * 0.004, 0, 1);
this.addWarScore(war, attacker.id, pressure * 0.08 + damageRate * 8);
const lastAdvance = war.lastFrontAdvanceYear instanceof Map ? (war.lastFrontAdvanceYear.get(attacker.id) ?? -Infinity) : -Infinity;
const canAdvanceFront =
memory > 1.05 &&
pressure > 0.58 &&
2026-09-16 20:53:17 +09:00
this.year - lastAdvance >= SimConfig.war.tickWeeks;
2026-09-16 13:51:43 +09:00
if (canAdvanceFront && this.rng.next() < clamp((memory - 0.9) * 0.10 + (pressure - 0.55) * 0.22, 0.02, 0.34)) {
const border = this.nearestBorderCampaignTile(attacker, defender);
if (border?.tile != null && Number.isFinite(border.distance)) {
const width = pressure > 1.05 ? 5 : 3;
const depth = memory > 1.8 ? 6 : 4;
const maxTiles = pressure > 1.25 || memory > 2.1 ? 120 : pressure > 0.9 ? 62 : 28;
const moved = this.transferFrontierBelt(attacker, defender, border.tile, width, depth, {
maxTiles,
reason: "frontAdvance",
intensity: clamp(0.38 + pressure * 0.18, 0.42, 0.72)
});
if (moved.length) {
war.lastActionYear = this.year;
war.lastFrontAdvanceYear ??= new Map();
war.lastFrontAdvanceYear.set(attacker.id, this.year);
this.addWarScore(war, attacker.id, moved.length * 0.018);
}
}
}
}
}
bestWarPressureTarget(attacker, defender, war, attackerPower = this.polityPower(attacker), defenderPower = this.polityPower(defender), probe = false) {
const attackerCities = this.getPolityCities(attacker);
const defenderCities = this.getPolityCities(defender);
if (!attackerCities.length || !defenderCities.length) return null;
const range = this.warTargetRange(attacker, defender, war, attackerPower, defenderPower);
const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0;
const defenderCohesion = clamp(defender.cohesion ?? 0.6, 0, 1);
const defenderLegitimacy = clamp(defender.legitimacy ?? 0.7, 0, 1);
const pressureMap = war.cityPressureById || new Map();
let best = null;
let bestScore = -Infinity;
for (const city of defenderCities) {
if (city.population <= 0) continue;
const capital = city.id === defender.centerCityId;
if (capital && defenderCities.length > 2 && war.goal !== "capital_pressure" && war.goal !== "collapse_exploitation") continue;
const connection = this.warConnectionToCity(attacker, city);
if (!connection.source || connection.distance > range) continue;
const memory = pressureMap.get(`${attacker.id}:${city.id}`) || 0;
const lowLoyalty = 1 - clamp(city.loyalty ?? 0.5, 0, 1);
const routeAccess = this.hasDirectTradeConnection(connection.source, city) || this.world.tradeRoute[this.world.idx(city.x, city.y)] ? 0.55 : 0;
const cityValue = Math.sqrt(city.population || 1) * 0.10 + (city.tradeValue || 0) * 0.55 + (capital ? 0.9 : 0);
const powerAdvantage = clamp(attackerPower / Math.max(1, defenderPower) - 1, -0.4, 2.4);
const deepPenalty = Math.max(0, connection.distance - SimConfig.polity.logisticsDistance) * (war.goal === "capital_pressure" ? 0.010 : 0.018);
2026-09-16 20:53:17 +09:00
const logisticsPenalty = (1 - connection.logisticsFactor) * 2.2;
2026-09-16 13:51:43 +09:00
const weakState = defenderInstability * 0.55 + (1 - defenderCohesion) * 0.35 + (1 - defenderLegitimacy) * 0.28;
const goalBonus =
war.goal === "punitive_raid" ? (city.storedResources || 0) * 0.004 :
war.goal === "border_claim" ? Math.max(0, 18 - connection.distance) * 0.035 :
war.goal === "collapse_exploitation" ? weakState * 0.9 + lowLoyalty * 0.45 :
war.goal === "capital_pressure" && capital ? 1.15 :
0.35;
2026-09-16 20:53:17 +09:00
const score = cityValue + lowLoyalty * 0.85 + routeAccess + memory * 0.45 + powerAdvantage * 0.45 + weakState + goalBonus - connection.distance * 0.018 - deepPenalty - logisticsPenalty + (probe ? 0 : this.rng.range(-0.08, 0.10));
2026-09-16 13:51:43 +09:00
if (score > bestScore) {
bestScore = score;
best = { city, connection, score };
}
}
return best;
}
warTargetRange(attacker, defender, war, attackerPower, defenderPower) {
const base = this.polityInfluenceRange(attacker, true);
const ratio = attackerPower / Math.max(1, defenderPower);
const routeReach = this.averagePolityTechnology(attacker) * 12;
const cohesion = clamp(attacker.cohesion ?? 0.6, 0, 1) * 8;
const weakDefender = (this.polityInstability ? this.polityInstability(defender) : 0) * 8;
const goalBoost = war.goal === "capital_pressure" ? 16 : war.goal === "collapse_exploitation" ? 14 : war.goal === "city_conquest" ? 8 : 0;
return clamp(base + clamp((ratio - 1) * 12, 0, 18) + routeReach + cohesion + weakDefender + goalBoost, 24, 82);
}
warConnectionToCity(polity, city) {
let source = null;
let distance = Infinity;
2026-09-16 20:53:17 +09:00
let logisticsFactor = 0;
let campaignCost = Infinity;
2026-09-16 13:51:43 +09:00
for (const c of this.getPolityCities(polity)) {
if (c.id === city.id) continue;
let d = this.effectiveDistance(c, city);
if (this.hasDirectTradeConnection(c, city)) d *= 0.72;
2026-09-16 20:53:17 +09:00
const logistics = this.warLogisticsFactor(polity, c, city);
const cost = d / Math.max(0.28, logistics);
if (cost < campaignCost) {
2026-09-16 13:51:43 +09:00
source = c;
distance = d;
2026-09-16 20:53:17 +09:00
logisticsFactor = logistics;
campaignCost = cost;
}
}
return { source, distance, connected: !!source, logisticsFactor, campaignCost };
}
warLogisticsFactor(polity, sourceCity, targetCity) {
if (!polity || !sourceCity || !targetCity) return 0.25;
const capital = this.getCityById(polity.centerCityId) || sourceCity;
const capitalDistance = this.effectiveDistance(capital, targetCity);
const distanceStart = SimConfig.polity.warCapitalDistanceStart ?? 18;
const distanceScale = SimConfig.polity.warCapitalDistanceScale ?? 42;
let factor = 1 / (1 + Math.max(0, capitalDistance - distanceStart) / Math.max(1, distanceScale));
const connected = sourceCity.id === capital.id || this.territoryRouteConnected(polity.id, capital, sourceCity);
if (!connected) factor *= SimConfig.polity.warDisconnectedTerritoryFactor ?? 0.42;
if (sourceCity.id !== capital.id && this.hasDirectTradeConnection(capital, sourceCity)) factor *= 1.08;
return clamp(factor, 0.18, 1);
}
territoryRouteConnected(polityId, fromCity, toCity) {
if (!fromCity || !toCity) return false;
if (fromCity.id === toCity.id) return true;
const key = `${polityId}:${fromCity.id}:${toCity.id}`;
if (this.warLogisticsCache?.has(key)) return this.warLogisticsCache.get(key);
const w = this.world;
const start = w.idx(fromCity.x, fromCity.y);
const goal = w.idx(toCity.x, toCity.y);
if (w.territoryOwner[start] !== polityId || w.territoryOwner[goal] !== polityId) {
this.warLogisticsCache?.set(key, false);
return false;
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
const visited = this.visitStamp;
const marker = this.nextVisitMarker();
const queue = this.scratchIntA;
let head = 0;
let tail = 0;
queue[tail++] = start;
visited[start] = marker;
let connected = false;
while (head < tail) {
const tile = queue[head++];
if (tile === goal) {
connected = true;
break;
}
this.forCardinalNeighbors(tile, neighbor => {
if (visited[neighbor] === marker || w.territoryOwner[neighbor] !== polityId || w.terrain[neighbor] === Terrain.WATER) return;
visited[neighbor] = marker;
queue[tail++] = neighbor;
});
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
this.warLogisticsCache?.set(key, connected);
return connected;
2026-09-16 13:51:43 +09:00
}
2026-09-16 20:53:17 +09:00
warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower, logisticsFactor = 1) {
2026-05-13 20:21:58 +09:00
const powerRatio = attackerPower / Math.max(1, defenderPower);
const proximity = 1 / (1 + distance * 0.055);
const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0;
const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0;
const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0;
const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1);
const techAdvantage = 1 + clamp(
this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender),
-0.25,
0.45
);
const nearest = this.nearestPolityCity(attacker, city);
const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92;
const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0;
return (
powerRatio *
proximity *
techAdvantage *
ethnicityFactor *
tradeFactor *
2026-09-16 20:53:17 +09:00
clamp(logisticsFactor, 0.18, 1) *
2026-05-13 20:21:58 +09:00
(1 + defenderInstability * 0.32) *
(1 + defenderAge * 0.18) *
(1 + defenderOverextension * 0.14) *
(0.65 + loyaltyWeakness * 0.72)
);
}
captureCityInWar(city, attacker, defender, war, pressure) {
2026-09-16 13:51:43 +09:00
const lossRate = clamp((SimConfig.war.captureLossBase ?? 0.08) + pressure * 0.055 + (war.intensity || 0.75) * 0.035, 0.06, SimConfig.war.captureLossMax ?? 0.32);
const loss = this.applyCityWarDamage(city, lossRate, "capture");
this.applyWarZoneTileDamage(this.world.idx(city.x, city.y), (SimConfig.war.warZoneTileLossRate ?? 0.006) * 3);
this.transferCityToPolity(city, attacker, {
loyalty: 0.28,
control: 0.62,
claim: 0.78,
allowForeignTakeover: true,
reason: "warCapture"
});
2026-05-13 20:21:58 +09:00
city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36);
war.lastActionYear = this.year;
2026-09-16 13:51:43 +09:00
if (war.aPolityId === attacker.id) war.capturedByA = (war.capturedByA || 0) + 1;
else war.capturedByB = (war.capturedByB || 0) + 1;
this.addWarScore(war, attacker.id, 1.8 + pressure * 0.7);
2026-05-13 20:21:58 +09:00
if (war.aPolityId === attacker.id) {
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1);
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1);
} else {
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1);
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1);
}
attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015);
attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5);
defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5);
defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1);
2026-09-16 13:51:43 +09:00
defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.045, 0, 1);
const advantage = this.polityPower(attacker) / Math.max(1, this.polityPower(defender));
const captureRadius = clamp(Math.round(7 + pressure * 5 + clamp(advantage - 1, 0, 1.8) * 4 + (war.intensity || 0.75) * 2), 6, 20);
const maxTiles = clamp(Math.round(28 + captureRadius * captureRadius * 0.38 + pressure * 26), 24, 150);
const transferred = this.transferWarTerritory(attacker, defender, this.world.idx(city.x, city.y), captureRadius, {
strength: clamp(0.52 + pressure * 0.10, 0.52, 0.78),
reason: "warCapture",
maxTiles
});
if (transferred.length) this.addWarScore(war, attacker.id, transferred.length * 0.012);
this.addPolityEvent(attacker.id, "war", this.year, { outcome: "capturedCity", cityId: city.id, targetPolityId: defender.id }, 2);
this.addPolityEvent(defender.id, "war", this.year, { outcome: "lostCity", cityId: city.id, targetPolityId: attacker.id }, 2);
2026-05-13 20:21:58 +09:00
}
applyWarExhaustion(war, a, b) {
const intensity = war.intensity || 0.75;
const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035;
const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035;
a.treasury = Math.max(0, (a.treasury || 0) - costA);
b.treasury = Math.max(0, (b.treasury || 0) - costB);
a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5);
b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5);
2026-09-16 20:53:17 +09:00
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08 * intensity, 0, 1);
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08 * intensity, 0, 1);
2026-09-16 13:51:43 +09:00
this.applyPolityWarExhaustionPopulationLoss(a, war.exhaustionA || 0, intensity);
this.applyPolityWarExhaustionPopulationLoss(b, war.exhaustionB || 0, intensity);
2026-05-13 20:21:58 +09:00
}
maybeEndWar(war) {
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
this.endWar(war);
return;
}
const age = this.year - war.started;
const noActionFor = this.year - war.lastActionYear;
const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0);
2026-09-16 20:53:17 +09:00
if (!this.getPolityCities(a).length || !this.getPolityCities(b).length) {
2026-05-13 20:21:58 +09:00
this.endWar(war);
return;
}
2026-09-16 20:53:17 +09:00
if (age >= SimConfig.war.minDurationWeeks && noActionFor >= SimConfig.war.idleEndWeeks) {
2026-05-13 20:21:58 +09:00
this.endWar(war);
return;
}
2026-09-16 20:53:17 +09:00
if (age >= SimConfig.war.minDurationWeeks && (exhaustion >= 0.8 || Math.abs((war.scoreA || 0) - (war.scoreB || 0)) >= SimConfig.war.decisiveScore)) {
2026-05-13 20:21:58 +09:00
this.endWar(war);
}
}
2026-09-16 13:51:43 +09:00
addWarScore(war, polityId, amount) {
if (!war || amount <= 0) return;
if (war.aPolityId === polityId) war.scoreA = (war.scoreA || 0) + amount;
else if (war.bPolityId === polityId) war.scoreB = (war.scoreB || 0) + amount;
}
applyCityWarDamage(city, rate, reason = "war") {
if (!city || city.population <= 1 || rate <= 0) return 0;
2026-09-16 20:53:17 +09:00
const damageMultiplier = SimConfig.war.damageMultiplier ?? 1;
const maxRate = reason === "capture" ? SimConfig.war.captureLossMax : SimConfig.war.cityDamageMaxRate;
const appliedRate = clamp(rate, 0, maxRate) * damageMultiplier;
const loss = Math.min(city.population - 1, Math.max(1, Math.floor(city.population * appliedRate)));
2026-09-16 13:51:43 +09:00
if (loss <= 0) return 0;
city.population = Math.max(1, city.population - loss);
if (city.ethnicityComposition) removeFromComposition(city.ethnicityComposition, loss);
city.storedResources = Math.max(0, (city.storedResources || 0) - loss * (reason === "capture" ? 0.38 : 0.16));
2026-09-16 20:53:17 +09:00
city.loyalty = clamp((city.loyalty ?? 0.5) - appliedRate * (reason === "capture" ? 1.8 : 0.9), 0, 1);
2026-09-16 13:51:43 +09:00
city.lastWarDamageYear = this.year;
this.recordWarDeaths(loss, reason === "capture" ? 0.45 : 0.65);
return loss;
}
2026-09-16 20:53:17 +09:00
2026-09-16 13:51:43 +09:00
applyPolityWarExhaustionPopulationLoss(polity, exhaustion, intensity) {
if (!polity || exhaustion <= 0.04) return 0;
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
const shortage = (polity.treasury || 0) <= 0 ? 0.35 : 0;
const crisis = clamp(polity.crisis || 0, 0, 1.5) * 0.15;
const activeWars = this.warCountForPolity(polity.id);
const cohesionStress = clamp(1 - (polity.cohesion ?? 0.6), 0, 1) * 0.22;
const rate = clamp(
(SimConfig.war.exhaustionPopulationLossBase ?? 0.0007) * (1 + exhaustion * 4.8 + intensity + activeWars * 0.35 + shortage + crisis + cohesionStress),
0,
SimConfig.war.exhaustionPopulationLossMax ?? 0.012
);
let total = 0;
for (const city of cities) {
const localRate = rate * (city.supplyStress > 0.8 ? 1.35 : 1);
if (localRate <= 0) continue;
total += this.applyCityWarDamage(city, localRate, "exhaustion");
city.loyalty = clamp((city.loyalty ?? 0.5) - exhaustion * 0.004, 0, 1);
}
if (exhaustion > 0.55) {
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - rate * 1.4, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - rate * 1.1, 0, 1);
}
return total;
}
2026-09-17 22:32:25 +09:00
applyOccupationPopulationShock(tile, radius = SimConfig.war.occupationPopulationLossRadius ?? 4, rate = SimConfig.war.occupationPopulationLossRate ?? 0.08) {
const w = this.world;
if (tile == null || tile < 0 || tile >= w.count || radius <= 0 || rate <= 0) return 0;
const cx = tile % w.size;
const cy = Math.floor(tile / w.size);
const damageMultiplier = SimConfig.war.damageMultiplier ?? 1;
let removed = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const distance = Math.abs(dx) + Math.abs(dy);
if (distance === 0 || distance > radius) continue;
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const t = w.idx(x, y);
if (w.terrain[t] === Terrain.WATER || w.population[t] <= 0) continue;
const falloff = 1 - distance / (radius + 1);
const amount = w.population[t] * rate * damageMultiplier * falloff;
if (amount <= 0.03) continue;
removed += this.removeEthnicPopulationProportionally(t, amount) || 0;
w.resource[t] = Math.max(0, w.resource[t] - amount * 0.015);
}
}
this.recordWarDeaths(removed, 0.72);
return removed;
}
2026-09-16 13:51:43 +09:00
applyWarZoneTileDamage(tile, rate) {
const w = this.world;
if (tile == null || tile < 0 || tile >= w.count || rate <= 0) return 0;
2026-09-16 20:53:17 +09:00
const damageMultiplier = SimConfig.war.damageMultiplier ?? 1;
2026-09-16 13:51:43 +09:00
let removed = 0;
for (const { dx, dy, distance } of this.getRadiusOffsets(2)) {
const x = tile % w.size + dx;
const y = Math.floor(tile / w.size) + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const t = w.idx(x, y);
if (w.terrain[t] === Terrain.WATER || w.population[t] <= 0) continue;
2026-09-16 20:53:17 +09:00
const localRate = rate * damageMultiplier * (1 - distance / 4);
2026-09-16 13:51:43 +09:00
const amount = w.population[t] * localRate;
if (amount <= 0.03) continue;
removed += this.removeEthnicPopulationProportionally(t, amount) || 0;
w.resource[t] = Math.max(0, w.resource[t] - amount * 0.02);
}
this.recordWarDeaths(removed, 0.8);
return removed;
}
2026-09-16 20:53:17 +09:00
2026-09-16 13:51:43 +09:00
recordWarDeaths(amount, civilianShare = 0.5) {
if (amount <= 0) return;
this.deaths += Math.max(0, Math.floor(amount * clamp(civilianShare, 0, 1)));
}
applyWarSettlement(war) {
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) return;
const margin = (war.scoreA || 0) - (war.scoreB || 0) + ((war.exhaustionB || 0) - (war.exhaustionA || 0)) * 1.2;
const threshold = SimConfig.war.majorSettlementThreshold ?? 1.25;
if (Math.abs(margin) < threshold) return;
const winner = margin > 0 ? a : b;
const loser = margin > 0 ? b : a;
const transferred = this.applyWarSettlementTransfer(winner, loser, war, Math.abs(margin));
loser.legitimacy = clamp((loser.legitimacy ?? 0.7) - 0.035 - Math.abs(margin) * 0.006, 0, 1);
loser.cohesion = clamp((loser.cohesion ?? 0.6) - 0.03 - (transferred ? 0.04 : 0), 0, 1);
loser.crisis = clamp((loser.crisis || 0) + 0.08 + Math.abs(margin) * 0.015, 0, 1.5);
for (const city of this.getPolityCities(loser)) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.025, 0, 1);
this.addPolityEvent(winner.id, "war", this.year, { outcome: transferred ? "settlementGain" : "settlementPressure", targetPolityId: loser.id }, 2);
this.addPolityEvent(loser.id, "war", this.year, { outcome: transferred ? "settlementLoss" : "settlementPressure", targetPolityId: winner.id }, 2);
}
applyWarSettlementTransfer(winner, loser, war, margin) {
const loserCities = this.getPolityCities(loser)
.filter(city => city.population > 0 && (city.id !== loser.centerCityId || margin > 2.8 || this.getPolityCities(loser).length <= 2))
.map(city => ({ city, connection: this.warConnectionToCity(winner, city), memory: war.cityPressureById?.get(`${winner.id}:${city.id}`) || 0 }))
.filter(entry => entry.connection.source && entry.connection.distance <= this.warTargetRange(winner, loser, war, this.polityPower(winner), this.polityPower(loser)) * 0.92)
.sort((a, b) => (b.memory + (1 - (b.city.loyalty ?? 0.5)) - b.connection.distance * 0.018) - (a.memory + (1 - (a.city.loyalty ?? 0.5)) - a.connection.distance * 0.018));
const entry = loserCities[0];
if (!entry || margin < 1.8) return false;
this.applyCityWarDamage(entry.city, clamp(0.025 + margin * 0.008, 0.02, 0.10), "settlement");
this.transferCityToPolity(entry.city, winner, {
loyalty: clamp(0.24 + (1 - (entry.city.loyalty ?? 0.5)) * 0.25, 0.18, 0.48),
control: 0.52,
claim: 0.66,
allowForeignTakeover: true,
reason: "settlement"
});
this.transferWarTerritory(winner, loser, this.world.idx(entry.city.x, entry.city.y), Math.max(7, SimConfig.campaign?.claimRadius ?? 8), {
strength: 0.50,
reason: "settlement",
maxTiles: 70
});
return true;
}
2026-05-13 20:21:58 +09:00
collectAndRedistributeResources() {
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
const centerId = polity.centerCityId;
for (const city of cities) city.receivedAid = false;
for (const city of cities) {
if (city.id === centerId) continue;
const tax = city.storedResources * 0.035;
city.storedResources -= tax;
polity.treasury += tax;
}
const agePressure = this.polityAgePressure(polity);
const overextension = this.polityOverextension(polity);
const adminCost =
cities.length * 0.24 +
overextension * 1.1 +
agePressure * cities.length * 0.18;
polity.treasury -= adminCost;
if (polity.treasury < 0) {
const deficit = Math.abs(polity.treasury);
polity.treasury = 0;
polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5);
}
const maintenance = Math.pow(cities.length, 1.12) * 0.10;
if (maintenance > 0) {
const center = this.getCityById(centerId);
const treasuryPayment = Math.min(polity.treasury, maintenance);
polity.treasury -= treasuryPayment;
const unpaid = maintenance - treasuryPayment;
if (unpaid > 0 && center) {
const paidByCenter = this.drainCityResources(center, unpaid);
if (paidByCenter < unpaid) {
for (const city of cities) {
if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1);
}
}
}
}
const poorCount = Math.ceil(cities.length * 0.1);
const poorest = [...cities]
.sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population)))
.slice(0, poorCount);
for (const city of poorest) {
const target = city.population * 0.08;
const need = target - city.storedResources;
if (need > 0 && polity.treasury > 0) {
const aid = Math.min(need, polity.treasury);
city.storedResources += aid;
polity.treasury -= aid;
city.receivedAid = true;
city.loyalty = clamp(city.loyalty + 0.05, 0, 1);
}
}
2026-09-16 20:53:17 +09:00
this.enforcePolityTreasuryCap(polity);
2026-05-13 20:21:58 +09:00
}
}
erodePolityLegitimacy() {
for (const polity of this.polities) {
const agePressure = this.polityAgePressure(polity);
const resourceStress = this.polityResourceStress(polity);
const overextension = this.polityOverextension(polity);
const erosion =
0.0015 +
agePressure * 0.003 +
resourceStress * 0.003 +
overextension * 0.0015;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1);
const cities = this.getPolityCities(polity);
if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) {
polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1);
polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1);
}
polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5);
}
}
triggerPolityCrises() {
for (const polity of this.polities) {
const age = this.polityAge(polity);
if (age < 140) continue;
2026-05-14 00:21:24 +09:00
if ((this.year - (polity.lastCrisisYear ?? 0)) / WEEKS_PER_YEAR < 160) continue;
2026-05-13 20:21:58 +09:00
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const chance = 0.006 + agePressure * 0.018 + instability * 0.014;
if (this.rng.next() >= chance) continue;
polity.lastCrisisYear = this.year;
const severity = clamp(
0.08 +
agePressure * this.rng.range(0.08, 0.20) +
instability * this.rng.range(0.05, 0.16),
0.05,
0.36
);
polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1);
const center = this.getCityById(polity.centerCityId);
for (const city of this.getPolityCities(polity)) {
if (city.id === polity.centerCityId) continue;
const distance = center ? this.effectiveDistance(center, city) : 40;
const distanceFactor = clamp(distance / 40, 0.25, 1);
city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1);
}
}
}
applyOldStateStress() {
for (const polity of this.polities) {
const age = this.polityAge(polity);
if (age < 1000) continue;
const cities = this.getPolityCities(polity);
if (cities.length <= 1) continue;
const oldAge = clamp((age - 1000) / 1000, 0, 1);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1);
polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5);
const center = this.getCityById(polity.centerCityId);
for (const city of cities) {
if (city.id === polity.centerCityId) continue;
const distance = center ? this.effectiveDistance(center, city) : 40;
const distanceFactor = clamp(distance / 35, 0.2, 1);
city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1);
}
}
}
updateCityLoyalty() {
for (const polity of this.polities) {
const center = this.getCityById(polity.centerCityId);
if (!center) continue;
2026-09-16 20:53:17 +09:00
const loyaltyCap = this.polityLoyaltyCap(polity);
center.loyalty = loyaltyCap;
2026-05-13 20:21:58 +09:00
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const overextension = this.polityOverextension(polity);
const logisticsStress = this.polityLogisticsStress(polity);
for (const city of this.getPolityCities(polity)) {
if (city.id === center.id) continue;
const perCapita = city.storedResources / Math.max(1, city.population);
const distance = this.effectiveDistance(city, center);
let delta = 0;
delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045);
delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012;
delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
2026-05-14 14:38:34 +09:00
const directCenterTrade = this.hasDirectTradeConnection(city, center);
2026-05-17 21:08:03 +09:00
if (directCenterTrade) delta += 0.025;
else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity);
2026-05-13 20:21:58 +09:00
if (city.receivedAid) delta += 0.04;
delta -= 0.004;
if (city.storedResources < city.population * 0.05) delta -= 0.035;
delta -= (city.supplyStress || 0) * 0.018;
delta -= instability * 0.020;
delta -= agePressure * 0.010;
delta -= overextension * clamp(distance / 48, 0, 1) * 0.014;
delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018;
if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020;
if ((polity.crisis || 0) > 0.6) delta -= 0.014;
2026-09-16 20:53:17 +09:00
city.loyalty = clamp(city.loyalty + delta, 0, loyaltyCap);
2026-05-13 20:21:58 +09:00
}
}
}
splitUnloyalCities() {
2026-09-16 20:53:17 +09:00
const cfg = SimConfig.rebellion || {};
const participationLoyalty = cfg.participationLoyalty ?? 0.50;
const automaticLoyalty = cfg.automaticLoyalty ?? 0.30;
for (const polity of [...this.polities]) {
const originalCities = this.getPolityCities(polity);
const preRebellionPopulation = originalCities.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
const members = originalCities
.filter(city => city.id !== polity.centerCityId && city.polityId === polity.id)
.filter(city => !this.isCityRebellionProtected(city))
.filter(city => (city.loyalty ?? 0.5) < participationLoyalty);
if (!members.length) continue;
const lowestLoyalty = Math.min(...members.map(city => city.loyalty ?? 0.5));
const automatic = lowestLoyalty < automaticLoyalty;
if (!automatic) {
2026-05-13 20:21:58 +09:00
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
2026-09-16 20:53:17 +09:00
const averageLoyalty = members.reduce((sum, city) => sum + (city.loyalty ?? 0.5), 0) / members.length;
const chance = clamp(
(participationLoyalty - averageLoyalty) * (cfg.chanceScale ?? 0.20) +
instability * 0.018 +
agePressure * 0.012,
0,
cfg.maxAnnualChance ?? 0.16
);
if (this.rng.next() >= chance) continue;
}
// Once a rebellion begins, every non-capital city below 50% loyalty joins
// simultaneously, regardless of distance from the first rebel city.
const trigger = members.reduce((lowest, city) =>
(city.loyalty ?? 0.5) < (lowest.loyalty ?? 0.5) ? city : lowest, members[0]);
const rebellionData = members.map(city => ({ cityId: city.id, population: city.population || 0, loyalty: city.loyalty ?? 0 }));
const oldPolityId = polity.id;
this.removeCityFromPolity(trigger);
this.releaseCityTerritory(trigger, oldPolityId, 5, "rebellion");
const rebelPolity = this.createPolity(trigger);
trigger.loyalty = 1;
for (const city of members) {
if (city.id === trigger.id) continue;
const loyalty = city.loyalty ?? 0.3;
2026-05-14 14:38:34 +09:00
this.removeCityFromPolity(city);
2026-09-16 13:51:43 +09:00
this.releaseCityTerritory(city, oldPolityId, 5, "rebellion");
2026-09-16 20:53:17 +09:00
this.transferCityToPolity(city, rebelPolity, {
loyalty: clamp(0.52 + (participationLoyalty - loyalty) * 0.38, 0.46, 0.72),
control: 0.48,
claim: 0.62,
reason: "rebellion"
2026-05-14 20:15:23 +09:00
});
2026-09-16 20:53:17 +09:00
this.claimPolityCoreTerritory(rebelPolity, city, 3, "rebellion");
}
const population = rebellionData.reduce((sum, rebellion) => sum + rebellion.population, 0);
const avgLoyalty = rebellionData.reduce((sum, rebellion) => sum + rebellion.loyalty, 0) / rebellionData.length;
const rebelPopulationShare = preRebellionPopulation > 0 ? clamp(population / preRebellionPopulation, 0, 1) : 0;
const transferRate = clamp(cfg.treasuryTransferRate ?? 0.65, 0, 1);
const treasuryTransferred = Math.min(
Math.max(0, polity.treasury || 0),
Math.max(0, polity.treasury || 0) * rebelPopulationShare * transferRate
);
polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryTransferred);
rebelPolity.treasury = Math.max(0, (rebelPolity.treasury || 0) + treasuryTransferred);
this.enforcePolityTreasuryCap(polity);
this.enforcePolityTreasuryCap(rebelPolity);
const eventData = {
cityIds: rebellionData.map(rebellion => rebellion.cityId),
cityCount: rebellionData.length,
population,
loyalty: avgLoyalty,
automatic,
treasuryTransferred,
newPolityId: rebelPolity.id
};
this.addPolityEvent(polity.id, "rebellion", this.year, eventData, 3);
this.addPolityEvent(rebelPolity.id, "rebellion", this.year, {
cityIds: eventData.cityIds,
cityCount: eventData.cityCount,
2026-05-14 20:15:23 +09:00
population,
2026-09-16 20:53:17 +09:00
automatic,
treasuryTransferred,
formerPolityId: polity.id
2026-05-14 20:15:23 +09:00
}, 3);
}
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
2026-05-13 20:21:58 +09:00
enforcePolityContinuity(polity) {
const center = this.getCityById(polity.centerCityId);
const cities = this.getPolityCities(polity);
if (!center || cities.length <= 1) return cities;
const limit = SimConfig.polity.logisticsDistance;
const reachable = new Set([center.id]);
const frontier = [center];
while (frontier.length) {
const source = frontier.pop();
for (const city of cities) {
if (reachable.has(city.id)) continue;
if (this.effectiveDistance(source, city) > limit) continue;
reachable.add(city.id);
frontier.push(city);
}
}
const isolated = cities.filter(city => !reachable.has(city.id));
if (!isolated.length) return cities;
for (const city of isolated) {
2026-09-16 13:51:43 +09:00
const oldPolityId = city.polityId;
2026-05-13 20:21:58 +09:00
this.removeCityFromPolity(city);
2026-09-16 13:51:43 +09:00
this.releaseCityTerritory(city, oldPolityId, 4, "fragmentation");
2026-05-14 20:15:23 +09:00
city.loyalty = clamp(city.loyalty - 0.28, 0, 1);
2026-05-13 20:21:58 +09:00
}
polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1);
polity.crisis = clamp((polity.crisis || 0) + isolated.length * 0.025, 0, 1.5);
return this.getPolityCities(polity);
}
detectPolityFamines() {
const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06;
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
if (!cities.length) continue;
let poorCities = 0;
let totalFood = 0;
let totalPopulation = 0;
for (const city of cities) {
const population = Math.max(1, city.population || 0);
const perCapita = (city.storedResources || 0) / population;
if (perCapita < minimumFood) poorCities++;
totalFood += city.storedResources || 0;
totalPopulation += population;
}
const poorRate = poorCities / cities.length;
const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation);
const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035;
if (!famine) continue;
if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue;
this.addPolityEvent(polity.id, "famine", this.year, {
poorRate,
avgPerCapitaFood
}, poorRate > 0.65 ? 3 : 2);
polity.lastFamineYear = this.year;
}
}
cleanupPolities() {
const survivors = [];
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
if (!cities.length) {
this.markPolityEnded(polity, "collapsed");
2026-09-16 13:51:43 +09:00
this.releasePolityTerritory(polity.id, "collapse");
2026-05-13 20:21:58 +09:00
continue;
}
let center = this.getCityById(polity.centerCityId);
if (!center || center.population <= 0) {
const oldCenterCityId = polity.centerCityId;
center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]);
polity.centerCityId = center.id;
2026-09-16 20:53:17 +09:00
center.loyalty = this.polityLoyaltyCap ? this.polityLoyaltyCap(polity) : 1;
2026-05-13 20:21:58 +09:00
if (oldCenterCityId !== center.id) {
this.addPolityEvent(polity.id, "capitalShift", this.year, {
oldCenterCityId,
newCenterCityId: center.id
}, 2);
}
polity.treasury *= 0.5;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1);
polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5);
polity.lastCrisisYear = this.year;
for (const city of cities) {
if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1);
}
const history = this.ensurePolityHistory(polity);
if (history) history.centerCityId = center.id;
}
const connectedCities = this.enforcePolityContinuity(polity);
if (connectedCities.length !== cities.length) {
cities.length = 0;
cities.push(...connectedCities);
}
if (cities.length === 1) {
2026-05-18 17:13:18 +09:00
const city = cities[0];
const ageYears = (this.year - (polity.founded ?? this.year)) / WEEKS_PER_YEAR;
const weakSingleCityState =
ageYears > 80 &&
city.population < 45 &&
(polity.legitimacy ?? 0.7) < 0.25 &&
(polity.cohesion ?? 0.6) < 0.25 &&
(polity.crisis || 0) > 0.9;
if (weakSingleCityState) {
this.markPolityEnded(polity, "collapsed");
2026-09-16 13:51:43 +09:00
this.releasePolityTerritory(polity.id, "collapse");
2026-05-18 17:13:18 +09:00
city.polityId = null;
city.loyalty = 0.45;
city.receivedAid = false;
continue;
}
2026-05-17 21:08:03 +09:00
}
2026-05-13 20:21:58 +09:00
survivors.push(polity);
}
this.polities = survivors;
this.rebuildIndexes();
const validPolities = new Set(this.polities.map(p => p.id));
for (const city of this.cities) {
if (city.polityId !== null && !validPolities.has(city.polityId)) {
2026-09-16 13:51:43 +09:00
const oldPolityId = city.polityId;
2026-05-13 20:21:58 +09:00
city.polityId = null;
city.loyalty = 0.45;
city.receivedAid = false;
2026-09-16 13:51:43 +09:00
this.releaseCityTerritory(city, oldPolityId, 5, "collapse");
2026-05-13 20:21:58 +09:00
}
}
}
2026-09-17 22:32:25 +09:00
updatePolities(force = false) {
if (!force && this.year % years(1) !== 0) return;
2026-05-13 20:21:58 +09:00
this.cleanupPolities();
this.foundPolities();
2026-09-17 22:32:25 +09:00
this.reinforcePolityTradeRoutes();
2026-05-13 20:21:58 +09:00
this.collectAndRedistributeResources();
2026-09-17 22:32:25 +09:00
this.maybeBuildTreasuryCities();
2026-05-13 20:21:58 +09:00
this.detectPolityFamines();
2026-09-17 22:32:25 +09:00
this.erodePolityLegitimacy();
this.updateInstitutionalAging();
this.maybeTriggerInstitutionalReforms();
this.triggerPolityCrises();
this.applyOldStateStress();
this.updatePolityLeaders();
2026-05-13 20:21:58 +09:00
this.updateCityLoyalty();
2026-05-17 21:08:03 +09:00
this.maybeStartCampaigns();
2026-05-13 20:21:58 +09:00
this.splitUnloyalCities();
2026-09-16 20:53:17 +09:00
this.enforcePolityTreasuryCaps();
2026-05-13 20:21:58 +09:00
this.cleanupPolities();
2026-09-17 22:32:25 +09:00
this.samplePolityHistories();
2026-09-16 20:53:17 +09:00
this.recomputeTerritories({ allowOwnershipChanges: true, rebalanceOwnership: true, reason: "normal" });
2026-09-16 13:51:43 +09:00
this.reconcileCityTerritoryOwnership();
2026-05-17 21:08:03 +09:00
this.campaignPressureOnTerritories();
2026-05-13 20:21:58 +09:00
}
reinforcePolityTradeRoutes() {
for (const polity of this.polities) {
if (this.rng.next() > 0.48) continue;
const center = this.getCityById(polity.centerCityId);
if (!center) continue;
const candidates = this.getPolityCities(polity)
.filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city))
.filter(city => this.distanceBetweenCities(center, city) <= 40)
.sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b));
if (!candidates.length) continue;
const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))];
2026-09-16 13:51:43 +09:00
const limit = this.effectiveRouteLengthLimit(center, target, polity);
const path = this.findTerrainRoute(center.x, center.y, target.x, target.y, limit, center, target, polity);
if (!this.isValidRoutePath(path, target.x, target.y, limit)) continue;
if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles)) continue;
2026-05-13 20:21:58 +09:00
const cities = this.getPolityCities(polity);
2026-09-16 13:51:43 +09:00
const roadCost = 5 + cities.length * 1.2 + this.polityOverextension(polity) * 2.4;
2026-05-13 20:21:58 +09:00
if ((polity.treasury || 0) < roadCost) {
polity.crisis = clamp((polity.crisis || 0) + 0.025, 0, 1.5);
continue;
}
polity.treasury -= roadCost;
if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
2026-05-14 00:21:24 +09:00
this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id);
2026-05-13 20:21:58 +09:00
}
}
2026-09-16 13:51:43 +09:00
routeCityLinkLimit(city) {
if (!city) return 1;
const capitalBonus = city.polityId !== null && this.getPolityById(city.polityId)?.centerCityId === city.id ? 1 : 0;
return clamp((city.population > 180 ? 4 : city.population > 90 ? 3 : 2) + capitalBonus, 2, 5);
}
2026-09-16 20:53:17 +09:00
desertCorridorShare(cityA, cityB, samples = 12) {
if (!cityA || !cityB) return 0;
const w = this.world;
let desert = 0;
let land = 0;
for (let step = 0; step <= samples; step++) {
const t = step / samples;
const x = clamp(Math.round(lerp(cityA.x, cityB.x, t)), 0, w.size - 1);
const y = clamp(Math.round(lerp(cityA.y, cityB.y, t)), 0, w.size - 1);
const terrain = w.terrain[w.idx(x, y)];
if (terrain === Terrain.WATER) continue;
land++;
if (terrain === Terrain.DESERT) desert++;
}
return land > 0 ? desert / land : 0;
}
routeDesertShare(path) {
if (!path?.length) return 0;
let desert = 0;
for (const tile of path) if (this.world.terrain[tile] === Terrain.DESERT) desert++;
return desert / path.length;
}
2026-09-16 13:51:43 +09:00
effectiveRouteLengthLimit(cityA = null, cityB = null, ownerPolity = null) {
const cfg = SimConfig.route;
const base = cfg.maxRouteLength ?? 42;
if (!cityA || !cityB) return base;
const avgPopulation = ((cityA.population || 1) + (cityB.population || 1)) * 0.5;
const avgKnowledge = (
(cityA.knowledge?.farming || 0) + (cityA.knowledge?.metallurgy || 0) +
(cityB.knowledge?.farming || 0) + (cityB.knowledge?.metallurgy || 0)
) * 0.25;
const samePolity = cityA.polityId !== null && cityA.polityId === cityB.polityId;
const capital = ownerPolity && (ownerPolity.centerCityId === cityA.id || ownerPolity.centerCityId === cityB.id) ? 1 : 0;
const org = ownerPolity ? clamp((ownerPolity.cohesion ?? 0.6) * 0.7 + (ownerPolity.legitimacy ?? 0.7) * 0.3, 0, 1.2) : 0;
const treasury = ownerPolity ? clamp(Math.sqrt(Math.max(0, ownerPolity.treasury || 0)) / 18, 0, 1.1) : 0;
2026-09-16 20:53:17 +09:00
const desertShare = this.desertCorridorShare(cityA, cityB);
const desertBonus = desertShare * (cfg.desertRouteLengthBonus ?? 30);
const normalMax = cfg.adaptiveMaxRouteLength ?? 72;
const desertMax = cfg.desertAdaptiveMaxRouteLength ?? normalMax;
const upperLimit = lerp(normalMax, desertMax, desertShare);
2026-09-16 13:51:43 +09:00
return Math.floor(clamp(
base +
clamp(Math.sqrt(avgPopulation) * 0.55, 0, 10) +
avgKnowledge * 12 +
(samePolity ? 7 : 0) +
capital * 6 +
org * 5 +
2026-09-16 20:53:17 +09:00
treasury * 4 +
desertBonus,
2026-09-16 13:51:43 +09:00
base,
2026-09-16 20:53:17 +09:00
upperLimit
2026-09-16 13:51:43 +09:00
));
}
cityHasRouteTilesNear(city, radius = SimConfig.route.isolatedRouteRadius ?? 4) {
if (!city) return false;
const w = this.world;
for (const { dx, dy } of this.getRadiusOffsets(radius)) {
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
if (w.tradeRoute[w.idx(x, y)] > 0) return true;
}
return false;
}
bootstrapIsolatedCityRoutes(supportedRoutes) {
const cfg = SimConfig.route;
if (!cfg.isolatedCityBootstrap || this.cities.length < 2) return;
let built = 0;
const maxBootstraps = clamp(Math.ceil(this.cities.length * 0.12), 4, cfg.isolatedRouteSearchLimit ?? 8);
const isolated = this.cities
.filter(city => city.population > 0 && (city.tradeLinks.size === 0 || !this.cityHasRouteTilesNear(city)))
.sort((a, b) => (a.tradeLinks.size - b.tradeLinks.size) || ((a.tradeReach || 0) - (b.tradeReach || 0)));
for (const city of isolated) {
if (built >= maxBootstraps) break;
if (city.tradeLinks.size > 0 && this.cityHasRouteTilesNear(city)) continue;
if (this.tryBootstrapCityRoute(city, supportedRoutes)) built++;
}
}
tryBootstrapCityRoute(city, supportedRoutes) {
const partners = this.bootstrapRoutePartners(city);
for (const partner of partners) {
if (!partner || partner.id === city.id || city.tradeLinks.has(partner.id)) continue;
const ownerPolityId = city.polityId !== null && city.polityId === partner.polityId ? city.polityId : null;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
const limit = this.effectiveRouteLengthLimit(city, partner, ownerPolity);
if (Math.abs(city.x - partner.x) + Math.abs(city.y - partner.y) > limit) continue;
const path = this.findTerrainRoute(city.x, city.y, partner.x, partner.y, limit, city, partner, ownerPolity);
if (!this.isValidRoutePath(path, partner.x, partner.y, limit)) continue;
const strength = Math.max(SimConfig.route.isolatedRouteMinStrength ?? 0.12, this.routeStrengthForPath(path) * 0.75);
if (!this.canPayRouteConstructionCost(city, partner, path, ownerPolity, { bootstrap: true })) continue;
if (!this.payRouteConstructionCost(city, partner, path, ownerPolity, { bootstrap: true })) continue;
this.registerTradeLink(
city,
partner,
strength,
path,
supportedRoutes,
SimConfig.route.newRouteGraceBoost ?? 18,
SimConfig.route.newRoutePheromoneSeed ?? 2.5,
ownerPolityId
);
this.exchangeCityResources(city, partner, strength, path);
this.applyRouteConnectionBenefits(city, partner, strength, SimConfig.route.routePheromoneMaintain ?? 0.12, true);
return true;
}
return false;
}
bootstrapRoutePartners(city) {
const result = [];
const seen = new Set([city.id]);
const add = candidate => {
if (candidate && candidate.id !== city.id && !seen.has(candidate.id)) {
seen.add(candidate.id);
result.push(candidate);
}
};
if (city.polityId !== null) {
const polity = this.getPolityById(city.polityId);
const capital = this.getCityById(polity?.centerCityId);
add(capital);
const samePolity = this.getPolityCities(polity || { cityIds: new Set() })
.filter(other => other.id !== city.id)
.sort((a, b) => this.distanceBetweenCities(city, a) - this.distanceBetweenCities(city, b));
for (const candidate of samePolity.slice(0, 4)) add(candidate);
}
const others = this.cities
.filter(other => other.id !== city.id)
.sort((a, b) => {
const polityBiasA = city.polityId !== null && a.polityId === city.polityId ? -12 : 0;
const polityBiasB = city.polityId !== null && b.polityId === city.polityId ? -12 : 0;
return this.distanceBetweenCities(city, a) + polityBiasA - (this.distanceBetweenCities(city, b) + polityBiasB);
});
for (const candidate of others.slice(0, 8)) add(candidate);
return result;
}
2026-09-16 20:53:17 +09:00
createManualTradeRoute(cityAId, cityBId) {
const cityA = this.getCityById(cityAId);
const cityB = this.getCityById(cityBId);
if (!cityA || !cityB) return { ok: false, reason: "missingCity" };
if (cityA.id === cityB.id) return { ok: false, reason: "sameCity" };
const cfg = SimConfig.route || {};
const direct = this.distanceBetweenCities(cityA, cityB);
const maxLength = cfg.manualRouteMaxLength ?? 120;
if (direct > maxLength) return { ok: false, reason: "tooFar", maxLength };
const ownerPolityId = cityA.polityId !== null && cityA.polityId === cityB.polityId ? cityA.polityId : null;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
const naturalLimit = this.effectiveRouteLengthLimit(cityA, cityB, ownerPolity);
const limit = Math.min(maxLength, Math.max(naturalLimit, Math.ceil(direct + 24)));
const path = this.findTerrainRoute(cityA.x, cityA.y, cityB.x, cityB.y, limit, cityA, cityB, ownerPolity);
if (!this.isValidRoutePath(path, cityB.x, cityB.y, limit)) return { ok: false, reason: "noPath" };
const protectionYears = cfg.manualProtectionYears ?? 25;
const protectedUntil = this.year + years(protectionYears);
const strength = Math.max(0.35, this.routeStrengthForPath(path));
this.manualTradeRoutes ??= [];
this.manualTradeRoutes = this.manualTradeRoutes.filter(route =>
!((route.from === cityA.id && route.to === cityB.id) || (route.from === cityB.id && route.to === cityA.id)));
const record = { from: cityA.id, to: cityB.id, path: [...path], strength, created: this.year, protectedUntil };
this.manualTradeRoutes.push(record);
this.manualRouteProtection ??= new Map();
for (const tile of path) {
if (this.world.terrain[tile] === Terrain.WATER) continue;
this.manualRouteProtection.set(tile, Math.max(this.manualRouteProtection.get(tile) || 0, protectedUntil));
this.world.tradeRoute[tile] = Math.max(this.world.tradeRoute[tile], cfg.manualProtectedStrength ?? 32);
this.activeTradeRouteTiles.add(tile);
this.addPheromone(tile, cfg.newRoutePheromoneSeed ?? 2.5);
}
const existingLink = this.tradeLinks.some(link => {
const endpoints = this.tradeLinkEndpointIds(link);
return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id);
});
cityA.tradeLinks.add(cityB.id);
cityB.tradeLinks.add(cityA.id);
if (!existingLink) {
this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path: [...path], ownerPolityId, manual: true, protectedUntil });
}
return { ok: true, path, protectionYears, protectedUntil };
}
cleanupManualTradeRouteProtection() {
this.manualTradeRoutes ??= [];
this.manualRouteProtection ??= new Map();
const active = [];
this.manualRouteProtection.clear();
for (const route of this.manualTradeRoutes) {
if (!route || route.protectedUntil <= this.year) continue;
const a = this.getCityById(route.from);
const b = this.getCityById(route.to);
if (!a || !b || !route.path?.length) continue;
active.push(route);
for (const tile of route.path) {
if (tile < 0 || tile >= this.world.count || this.world.terrain[tile] === Terrain.WATER) continue;
this.manualRouteProtection.set(tile, Math.max(this.manualRouteProtection.get(tile) || 0, route.protectedUntil));
}
}
this.manualTradeRoutes = active;
}
isManualRouteTileProtected(tile) {
return (this.manualRouteProtection?.get(tile) || 0) > this.year;
}
restoreProtectedManualRoutes(supportedRoutes) {
const cfg = SimConfig.route || {};
for (const route of this.manualTradeRoutes || []) {
if (route.protectedUntil <= this.year) continue;
const cityA = this.getCityById(route.from);
const cityB = this.getCityById(route.to);
if (!cityA || !cityB) continue;
cityA.tradeLinks.add(cityB.id);
cityB.tradeLinks.add(cityA.id);
const ownerPolityId = cityA.polityId !== null && cityA.polityId === cityB.polityId ? cityA.polityId : null;
const exists = this.tradeLinks.some(link =>
(link.from === cityA.id && link.to === cityB.id) || (link.from === cityB.id && link.to === cityA.id));
if (!exists) this.tradeLinks.push({ ...route, ownerPolityId, manual: true });
for (const tile of route.path) {
if (tile < 0 || tile >= this.world.count || this.world.terrain[tile] === Terrain.WATER) continue;
if (supportedRoutes) supportedRoutes.add(tile);
this.world.tradeRoute[tile] = Math.max(this.world.tradeRoute[tile], cfg.manualProtectedStrength ?? 32);
this.activeTradeRouteTiles.add(tile);
}
}
}
2026-09-16 13:51:43 +09:00
updateTradeRoutes(fieldBased = false) {
2026-05-13 20:21:58 +09:00
const w = this.world;
2026-09-16 20:53:17 +09:00
this.cleanupManualTradeRouteProtection();
2026-05-13 20:21:58 +09:00
for (let i = 0; i < w.count; i++) {
2026-09-16 13:51:43 +09:00
const invalidTerrain = w.terrain[i] === Terrain.WATER;
2026-05-13 20:21:58 +09:00
if (invalidTerrain) w.tradeRoute[i] = 0;
2026-09-16 20:53:17 +09:00
else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0 && !this.isManualRouteTileProtected(i)) {
2026-05-14 00:21:24 +09:00
const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
const decay = pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 1 : SimConfig.route.routeUnsupportedDecay;
w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - decay);
if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0;
}
2026-05-13 20:21:58 +09:00
}
this.tradeLinks = [];
for (const city of this.cities) city.tradeLinks.clear();
2026-09-16 20:53:17 +09:00
const supportedRoutes = this.supportedRouteScratch;
supportedRoutes.clear();
this.restoreProtectedManualRoutes(supportedRoutes);
2026-05-13 20:21:58 +09:00
const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400);
2026-09-16 13:51:43 +09:00
const pairHeap = this.pairCandidateScratch;
pairHeap.length = 0;
const sqrtPop = this.cityScoreScratch;
sqrtPop.length = 0;
for (const city of this.cities) sqrtPop[city.id] = Math.sqrt(city.population || 1);
2026-05-13 20:21:58 +09:00
for (let a = 0; a < this.cities.length; a++) {
for (let b = a + 1; b < this.cities.length; b++) {
const c1 = this.cities[a];
const c2 = this.cities[b];
const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
2026-09-16 13:51:43 +09:00
const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
const limit = this.effectiveRouteLengthLimit(c1, c2, ownerPolity);
if (d > limit) continue;
2026-05-13 20:21:58 +09:00
const distanceFactor = clamp((d - 14) / 30, 0, 1);
2026-09-16 13:51:43 +09:00
const marketScale = clamp(((sqrtPop[c1.id] || 1) + (sqrtPop[c2.id] || 1)) / 34, 0.45, 2.1);
2026-05-13 20:21:58 +09:00
const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0;
2026-09-16 13:51:43 +09:00
const isolatedBias = (!this.cityHasRouteTilesNear(c1) ? 0.55 : 0) + (!this.cityHasRouteTilesNear(c2) ? 0.55 : 0);
const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias + isolatedBias + (ownerPolityId !== null ? 0.18 : 0);
this.topHeapPush(pairHeap, { c1, c2, d, limit, distanceFactor, marketScale, preliminaryScore, ownerPolityId }, maxPairsToRoute, "preliminaryScore");
2026-05-13 20:21:58 +09:00
}
}
2026-09-16 13:51:43 +09:00
const pairCandidates = this.heapToDescending(pairHeap, "preliminaryScore");
const candidates = this.routeCandidateScratch;
candidates.length = 0;
const maxLinks = Math.max(1, Math.floor(this.cities.length / 2));
const routeCandidateLimit = clamp(maxLinks * 12, 80, maxPairsToRoute);
const maxPathAttempts = clamp(maxLinks * 16, 120, 520);
let pathAttempts = 0;
for (const pair of pairCandidates) {
if (pathAttempts++ >= maxPathAttempts) break;
const { c1, c2, distanceFactor, marketScale, ownerPolityId } = pair;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
const limit = pair.limit || this.effectiveRouteLengthLimit(c1, c2, ownerPolity);
const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y, limit, c1, c2, ownerPolity);
if (!this.isValidRoutePath(path, c2.x, c2.y, limit)) continue;
2026-05-13 20:21:58 +09:00
const strength = this.routeStrengthForPath(path);
2026-05-14 00:21:24 +09:00
const pheromoneSupport = this.routePheromoneSupport(path);
2026-05-13 20:21:58 +09:00
if (strength < 0.08) continue;
2026-09-16 20:53:17 +09:00
const desertShare = this.routeDesertShare(path);
const desertThresholdFactor = SimConfig.route.desertPheromoneThresholdFactor ?? 0.45;
const buildThreshold = SimConfig.route.routePheromoneBuild * (1 - desertShare * (1 - desertThresholdFactor));
if (!fieldBased && ownerPolityId === null && pheromoneSupport < buildThreshold) continue;
2026-05-14 00:21:24 +09:00
if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue;
const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale;
2026-09-16 13:51:43 +09:00
this.topHeapPush(candidates, { c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }, routeCandidateLimit, "tradeScore");
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
this.heapToDescending(candidates, "tradeScore");
2026-05-13 20:21:58 +09:00
for (const candidate of candidates) {
if (this.tradeLinks.length >= maxLinks) break;
2026-05-14 00:21:24 +09:00
const { c1, c2, strength, path, pheromoneSupport, ownerPolityId } = candidate;
2026-09-16 13:51:43 +09:00
const c1Limit = this.routeCityLinkLimit(c1);
const c2Limit = this.routeCityLinkLimit(c2);
2026-05-13 20:21:58 +09:00
if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue;
2026-05-14 00:21:24 +09:00
if (this.hasReservedRouteSegment(path, supportedRoutes)) continue;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
if (!this.payRouteConstructionCost(c1, c2, path, ownerPolity)) continue;
const upkeepPaid = this.payRouteUpkeep(c1, c2, path, ownerPolity, pheromoneSupport);
2026-09-16 13:51:43 +09:00
if (!fieldBased && !upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) {
2026-05-14 00:21:24 +09:00
this.weakenRoutePath(path, SimConfig.route.routeUnsupportedDecay);
continue;
2026-05-13 20:21:58 +09:00
}
2026-05-14 00:21:24 +09:00
const routeBoost = upkeepPaid ? SimConfig.route.routeMaintainedBoost : Math.floor(SimConfig.route.routeMaintainedBoost * 0.45);
2026-09-16 13:51:43 +09:00
this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, fieldBased ? 0.8 : 0, ownerPolityId);
2026-05-14 00:21:24 +09:00
this.exchangeCityResources(c1, c2, strength, path);
this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid);
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
this.bootstrapIsolatedCityRoutes(supportedRoutes);
2026-05-13 20:21:58 +09:00
for (let i = 0; i < w.count; i++) {
2026-05-14 00:21:24 +09:00
if (w.tradeRoute[i] && !supportedRoutes.has(i)) {
2026-09-16 20:53:17 +09:00
w.tradeRoute[i] = 0;
2026-05-14 00:21:24 +09:00
}
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
this.activeTradeRouteTiles.clear();
2026-05-13 20:21:58 +09:00
for (let i = 0; i < w.count; i++) {
if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i);
}
this.diffuseCityKnowledgeThroughTrade();
}
2026-09-16 20:53:17 +09:00
pruneOrphanTradeRoutes() {
const w = this.world;
this.cleanupManualTradeRouteProtection();
const validLinks = [];
const validTiles = new Set();
for (const link of this.tradeLinks || []) {
if (link.manual && link.protectedUntil != null && link.protectedUntil <= this.year) continue;
const a = this.getCityById(link.from);
const b = this.getCityById(link.to);
const path = link.path || [];
if (!a || !b || !path.length) continue;
const first = path[0];
const last = path[path.length - 1];
const endpointDistance = (tile, city) => Math.abs(tile % w.size - city.x) + Math.abs(Math.floor(tile / w.size) - city.y);
const aligned =
(endpointDistance(first, a) <= 1 && endpointDistance(last, b) <= 1) ||
(endpointDistance(first, b) <= 1 && endpointDistance(last, a) <= 1);
if (!aligned) continue;
validLinks.push(link);
for (const tile of path) validTiles.add(tile);
}
// Protected user-created routes remain valid even if a normal route refresh has
// not run since they were created. Once protection expires they are omitted and
// can disappear under the ordinary route rules.
for (const route of this.manualTradeRoutes || []) {
if (route.protectedUntil <= this.year) continue;
const a = this.getCityById(route.from);
const b = this.getCityById(route.to);
if (!a || !b || !route.path?.length) continue;
const already = validLinks.some(link => {
const endpoints = this.tradeLinkEndpointIds(link);
return endpoints && endpoints.includes(a.id) && endpoints.includes(b.id);
});
if (!already) validLinks.push({ ...route, manual: true });
for (const tile of route.path) if (w.terrain[tile] !== Terrain.WATER) validTiles.add(tile);
}
this.tradeLinks = validLinks;
for (const city of this.cities) {
city.tradeLinks ??= new Set();
city.tradeLinks.clear();
}
for (const link of validLinks) {
const a = this.getCityById(link.from);
const b = this.getCityById(link.to);
if (!a || !b) continue;
a.tradeLinks.add(b.id);
b.tradeLinks.add(a.id);
}
for (let tile = 0; tile < w.count; tile++) if (w.tradeRoute[tile] && !validTiles.has(tile)) w.tradeRoute[tile] = 0;
this.activeTradeRouteTiles.clear();
for (const tile of validTiles) if (w.tradeRoute[tile]) this.activeTradeRouteTiles.add(tile);
}
2026-05-14 00:21:24 +09:00
registerTradeLink(cityA, cityB, strength, path, supportedRoutes = null, routeBoost = 18, pheromoneBoost = 0, ownerPolityId = null) {
cityA.tradeLinks.add(cityB.id);
cityB.tradeLinks.add(cityA.id);
this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolityId });
for (const tile of path) {
if (supportedRoutes) supportedRoutes.add(tile);
this.world.tradeRoute[tile] = Math.min(255, this.world.tradeRoute[tile] + routeBoost);
2026-09-16 13:51:43 +09:00
this.activeTradeRouteTiles.add(tile);
2026-05-14 00:21:24 +09:00
this.addPheromone(tile, pheromoneBoost);
}
}
2026-05-13 20:21:58 +09:00
diffuseCityKnowledgeThroughTrade() {
for (const link of this.tradeLinks) {
const a = this.getCityById(link.from);
const b = this.getCityById(link.to);
if (!a || !b) continue;
a.knowledge ??= { farming: 0, metallurgy: 0 };
b.knowledge ??= { farming: 0, metallurgy: 0 };
const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012);
const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate;
const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate;
a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1);
b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1);
a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1);
b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1);
}
}
2026-09-16 13:51:43 +09:00
findTerrainRoute(x1, y1, x2, y2, lengthLimit = SimConfig.route.maxRouteLength, cityA = null, cityB = null, ownerPolity = null) {
2026-05-13 20:21:58 +09:00
const w = this.world;
2026-09-16 13:51:43 +09:00
const start = w.idx(x1, y1);
const goal = w.idx(x2, y2);
if (w.terrain[start] === Terrain.WATER || w.terrain[goal] === Terrain.WATER) return [];
const limit = Math.max(1, Math.floor(lengthLimit || this.effectiveRouteLengthLimit(cityA, cityB, ownerPolity)));
const direct = Math.abs(x1 - x2) + Math.abs(y1 - y2);
if (direct > limit) return [];
const padding = clamp(Math.ceil(limit * 0.35), 8, 22);
const minX = Math.max(0, Math.min(x1, x2) - padding);
const maxX = Math.min(w.size - 1, Math.max(x1, x2) + padding);
const minY = Math.max(0, Math.min(y1, y2) - padding);
const maxY = Math.min(w.size - 1, Math.max(y1, y2) + padding);
const costs = this.routeCostScratch;
const prev = this.routePrevScratch;
const seen = this.visitStamp;
const marker = this.nextVisitMarker();
const heap = this.routeHeapScratch;
heap.length = 0;
costs[start] = 0;
prev[start] = -1;
seen[start] = marker;
this.routeHeapPush(heap, { tile: start, score: direct });
let reached = false;
let expansions = 0;
const maxExpansions = clamp(limit * limit * 3, 420, 5200);
while (heap.length && expansions++ < maxExpansions) {
const current = this.routeHeapPop(heap);
const tile = current.tile;
if (tile === goal) {
reached = true;
break;
}
const cx = tile % w.size;
const cy = Math.floor(tile / w.size);
this.forCardinalNeighbors(tile, (n, nx, ny) => {
if (nx < minX || nx > maxX || ny < minY || ny > maxY) return;
if (w.terrain[n] === Terrain.WATER) return;
const stepsFromStart = Math.abs(nx - x1) + Math.abs(ny - y1);
const remaining = Math.abs(nx - x2) + Math.abs(ny - y2);
if (stepsFromStart + remaining > limit + 8) return;
const terrainCost = this.routeTileCost(n);
const nextCost = costs[tile] + terrainCost;
if (seen[n] === marker && nextCost >= costs[n]) return;
seen[n] = marker;
costs[n] = nextCost;
prev[n] = tile;
const heuristic = remaining * 1.08;
this.routeHeapPush(heap, { tile: n, score: nextCost + heuristic });
});
}
if (!reached) return [];
2026-05-13 20:21:58 +09:00
const path = [];
2026-09-16 13:51:43 +09:00
let current = goal;
while (current >= 0 && path.length <= limit + 8) {
2026-05-13 20:21:58 +09:00
path.push(current);
2026-09-16 13:51:43 +09:00
if (current === start) break;
current = prev[current];
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
if (path[path.length - 1] !== start) return [];
path.reverse();
2026-05-13 20:21:58 +09:00
return path;
}
2026-09-16 13:51:43 +09:00
routeHeapPush(heap, node) {
heap.push(node);
let index = heap.length - 1;
while (index > 0) {
const parent = (index - 1) >> 1;
if (heap[parent].score <= node.score) break;
heap[index] = heap[parent];
index = parent;
}
heap[index] = node;
}
routeHeapPop(heap) {
const root = heap[0];
const last = heap.pop();
if (heap.length && last) {
let index = 0;
while (true) {
let child = index * 2 + 1;
if (child >= heap.length) break;
if (child + 1 < heap.length && heap[child + 1].score < heap[child].score) child++;
if (heap[child].score >= last.score) break;
heap[index] = heap[child];
index = child;
}
heap[index] = last;
}
return root;
}
routeTileCost(tile) {
const w = this.world;
const routeRelief = w.tradeRoute[tile] ? 0.58 : 1;
const pheromoneRelief = 1 - clamp(w.pheromone[tile] / SimConfig.route.maxPheromone, 0, 0.32);
const mountain = w.terrain[tile] === Terrain.MOUNTAIN ? 1.2 : 0;
2026-09-16 20:53:17 +09:00
const desertFactor = w.terrain[tile] === Terrain.DESERT ? (SimConfig.route.desertTraversalCostFactor ?? 0.68) : 1;
return Math.max(0.45, (w.move[tile] * desertFactor + mountain) * routeRelief * pheromoneRelief);
2026-09-16 13:51:43 +09:00
}
2026-05-13 20:21:58 +09:00
routeStrengthForPath(path) {
const w = this.world;
if (!path.length) return 0;
let route = 0;
let pheromone = 0;
let terrainEase = 0;
for (const i of path) {
route += w.tradeRoute[i] > 0 ? 1 : 0;
pheromone += clamp(w.pheromone[i] / 9, 0, 1);
2026-09-16 20:53:17 +09:00
terrainEase += 1 / Math.max(0.75, this.routeTileCost(i));
2026-05-13 20:21:58 +09:00
}
return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2;
}
2026-05-14 00:21:24 +09:00
hasReservedRouteSegment(path, reservedTiles) {
let previousReserved = false;
for (let p = 1; p < path.length - 1; p++) {
const reserved = reservedTiles.has(path[p]);
if (reserved && previousReserved) return true;
previousReserved = reserved;
}
return false;
}
routePheromoneSupport(path) {
const w = this.world;
if (!path.length) return 0;
let support = 0;
for (const i of path) support += clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
return support / path.length;
}
2026-09-16 13:51:43 +09:00
isValidRoutePath(path, targetX, targetY, lengthLimit = SimConfig.route.maxRouteLength) {
2026-05-13 20:21:58 +09:00
const w = this.world;
if (!path.length) return false;
const last = path[path.length - 1];
if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false;
2026-09-16 13:51:43 +09:00
const limit = lengthLimit;
if (path.length - 1 > limit) return false;
2026-05-13 20:21:58 +09:00
const seen = new Set();
for (const tile of path) {
if (seen.has(tile)) return false;
seen.add(tile);
if (w.terrain[tile] === Terrain.WATER) return false;
}
2026-09-16 13:51:43 +09:00
return true;
2026-05-13 20:21:58 +09:00
}
exchangeCityResources(a, b, strength, path) {
const delta = (a.storedResources - b.storedResources) * 0.018 * strength;
a.storedResources -= delta;
b.storedResources += delta;
const traffic = Math.min(0.8, strength * 0.26);
this.depositRoutePheromone(path, traffic);
}
2026-05-14 00:21:24 +09:00
routeUpkeepCost(path, pheromoneSupport) {
const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55;
2026-09-16 13:51:43 +09:00
const difficulty = this.routePathDifficulty(path);
return path.length * SimConfig.route.routeUpkeepPerTile * (0.65 + difficulty * 0.35) * (1 - pheromoneRelief);
2026-05-14 00:21:24 +09:00
}
payRouteUpkeep(cityA, cityB, path, polity = null, pheromoneSupport = 0) {
const cost = this.routeUpkeepCost(path, pheromoneSupport);
if (cost <= 0.001) return true;
let remaining = cost;
if (polity) {
const paid = Math.min(polity.treasury || 0, remaining * 0.72);
polity.treasury = Math.max(0, (polity.treasury || 0) - paid);
remaining -= paid;
}
const cityShare = remaining * 0.5;
remaining -= this.drainCityResources(cityA, cityShare);
remaining -= this.drainCityResources(cityB, cityShare);
if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
if (remaining > 0 && polity) {
const paid = Math.min(polity.treasury || 0, remaining);
polity.treasury = Math.max(0, (polity.treasury || 0) - paid);
remaining -= paid;
}
return remaining <= cost * 0.25;
}
weakenRoutePath(path, amount) {
for (const tile of path) {
this.world.tradeRoute[tile] = Math.max(0, this.world.tradeRoute[tile] - amount);
if (this.world.tradeRoute[tile] < SimConfig.route.routeWeakThreshold) this.world.tradeRoute[tile] = 0;
}
}
applyRouteConnectionBenefits(cityA, cityB, strength, pheromoneSupport, upkeepPaid) {
const reliability = clamp(strength * 0.65 + pheromoneSupport * 0.35, 0, 1) * (upkeepPaid ? 1 : 0.55);
if (reliability <= 0) return;
const tradeValue = reliability * 0.18;
cityA.tradeValue = (cityA.tradeValue || 0) + tradeValue;
cityB.tradeValue = (cityB.tradeValue || 0) + tradeValue;
cityA.tradeReach = Math.max(cityA.tradeReach || 0, reliability);
cityB.tradeReach = Math.max(cityB.tradeReach || 0, reliability);
const resourceBonus = reliability * 0.12;
cityA.storedResources += resourceBonus;
cityB.storedResources += resourceBonus;
if (cityA.polityId !== null && cityA.polityId === cityB.polityId) {
cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1);
cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1);
}
}
2026-09-16 13:51:43 +09:00
routePathDifficulty(path) {
if (!path.length) return 1;
let difficulty = 0;
2026-05-13 20:21:58 +09:00
for (const tile of path) {
2026-09-16 20:53:17 +09:00
let move = this.world.move[tile] || 1;
if (this.world.terrain[tile] === Terrain.DESERT) move *= SimConfig.route.desertTraversalCostFactor ?? 0.68;
2026-09-16 13:51:43 +09:00
const hard = move > 1.8 ? 0.35 : 0;
const existing = this.world.tradeRoute[tile] ? -0.25 : 0;
difficulty += Math.max(0.45, move + hard + existing);
}
return difficulty / path.length;
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
routeConstructionCost(path, polityBacked = false, options = {}) {
let weakTiles = 0;
for (const tile of path) if (this.world.tradeRoute[tile] < 24) weakTiles++;
const difficulty = this.routePathDifficulty(path);
2026-09-16 20:53:17 +09:00
const desertShare = this.routeDesertShare(path);
const desertFactor = SimConfig.route.desertConstructionCostFactor ?? 0.72;
const terrainConstructionFactor = 1 - desertShare * (1 - desertFactor);
const base = weakTiles * (polityBacked ? 0.16 : 0.12) * clamp(difficulty / 1.35, 0.75, 2.1) * terrainConstructionFactor;
2026-09-16 13:51:43 +09:00
return options.bootstrap ? base * 0.62 : base;
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
canPayRouteConstructionCost(cityA, cityB, path, polity = null, options = {}) {
const cost = this.routeConstructionCost(path, !!polity, options);
2026-05-13 20:21:58 +09:00
if (cost <= 0) return true;
2026-09-16 13:51:43 +09:00
const reserve = options.bootstrap ? Math.max(2.5, Math.sqrt((cityA.population || 1) + (cityB.population || 1)) * 0.25) : 0;
return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) + reserve >= cost;
2026-05-13 20:21:58 +09:00
}
2026-09-16 13:51:43 +09:00
payRouteConstructionCost(cityA, cityB, path, polity = null, options = {}) {
const cost = this.routeConstructionCost(path, !!polity, options);
2026-05-13 20:21:58 +09:00
if (cost <= 0) return true;
2026-09-16 13:51:43 +09:00
if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity, options)) return false;
2026-05-13 20:21:58 +09:00
let remaining = cost;
if (polity) {
const treasuryPayment = Math.min(polity.treasury, cost * 0.65);
polity.treasury -= treasuryPayment;
remaining -= treasuryPayment;
}
const half = remaining * 0.5;
const paidA = this.drainCityResources(cityA, half);
const paidB = this.drainCityResources(cityB, half);
remaining -= paidA + paidB;
if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
if (remaining > 0 && polity) {
const treasuryPayment = Math.min(polity.treasury, remaining);
polity.treasury -= treasuryPayment;
remaining -= treasuryPayment;
}
2026-09-16 13:51:43 +09:00
if (options.bootstrap && remaining <= Math.max(2.5, Math.sqrt((cityA.population || 1) + (cityB.population || 1)) * 0.25)) return true;
2026-05-13 20:21:58 +09:00
return remaining <= 0.001;
}
drainCityResources(city, amount) {
const paid = Math.min(city.storedResources, amount);
city.storedResources -= paid;
return paid;
}
2026-05-14 00:21:24 +09:00
addPheromone(tile, amount) {
if (tile < 0 || tile >= this.world.count || amount <= 0) return;
this.world.pheromone[tile] = Math.min(SimConfig.route.maxPheromone, this.world.pheromone[tile] + amount);
2026-09-16 20:53:17 +09:00
if (this.world.pheromone[tile] > 0) this.activePheromoneTiles?.add(tile);
2026-05-14 00:21:24 +09:00
}
2026-05-13 20:21:58 +09:00
depositRoutePheromone(path, amount) {
for (const i of path) {
2026-05-14 00:21:24 +09:00
this.addPheromone(i, amount);
2026-05-13 20:21:58 +09:00
}
}
updateEthnicStats() {
for (const e of this.ethnicities.values()) {
e.population = 0;
e.diversity = 0;
e.centroidX = 0;
e.centroidY = 0;
}
2026-09-16 13:51:43 +09:00
for (const band of this.nomadBands || []) {
const e = this.ethnicities.get(band.ethnicityId);
2026-05-13 20:21:58 +09:00
if (!e) continue;
2026-09-16 13:51:43 +09:00
e.population += band.population;
e.centroidX += band.x * band.population;
e.centroidY += band.y * band.population;
e.diversity += (1 - band.cohesion) * band.population;
}
for (const [tile, mix] of this.tileEthnicMix) {
const x = tile % this.world.size;
const y = Math.floor(tile / this.world.size);
const diversity = this.world.cultureDiversity[tile] || 0;
for (const [id, count] of mix) {
const e = this.ethnicities.get(id);
if (!e || count <= 0) continue;
e.population += count;
e.centroidX += x * count;
e.centroidY += y * count;
e.diversity += diversity * count;
}
2026-05-13 20:21:58 +09:00
}
for (const city of this.cities) {
for (const [id, count] of city.ethnicityComposition) {
const e = this.ethnicities.get(id);
2026-09-16 13:51:43 +09:00
if (!e || count <= 0) continue;
e.population += count * 0.25;
e.centroidX += city.x * count * 0.25;
e.centroidY += city.y * count * 0.25;
2026-05-13 20:21:58 +09:00
}
}
for (const e of this.ethnicities.values()) {
if (!e.population) continue;
e.centroidX /= e.population;
e.centroidY /= e.population;
2026-09-16 13:51:43 +09:00
e.diversity /= e.population;
e.averageTraits ??= this.randomTraits();
2026-05-13 20:21:58 +09:00
}
}
2026-09-16 20:53:17 +09:00
absorptionTargetForEthnicity(ethnicityId) {
const ethnicity = this.ethnicities.get(ethnicityId);
if (!ethnicity) return null;
const scores = new Map();
const addScore = (id, amount) => {
if (id == null || id === ethnicityId || amount <= 0 || !this.ethnicities.has(id)) return;
scores.set(id, (scores.get(id) || 0) + amount);
};
for (const [tile, mix] of this.tileEthnicMix) {
const local = mix.get(ethnicityId) || 0;
if (local <= 0) continue;
for (const [id, count] of mix) addScore(id, count * 1.5);
const begin = this.world.landNeighborStarts?.[tile] ?? 0;
const end = this.world.landNeighborStarts?.[tile + 1] ?? begin;
for (let index = begin; index < end; index++) {
const neighborMix = this.tileEthnicMix.get(this.world.landNeighbors[index]);
if (!neighborMix) continue;
for (const [id, count] of neighborMix) addScore(id, count);
}
}
for (const city of this.cities) {
const local = city.ethnicityComposition?.get(ethnicityId) || 0;
if (local <= 0) continue;
for (const [id, count] of city.ethnicityComposition) addScore(id, count * 1.25);
}
let bestId = null;
let bestScore = -Infinity;
for (const [id, score] of scores) {
if (score > bestScore) { bestId = id; bestScore = score; }
}
if (bestId !== null) return bestId;
const parent = ethnicity.parent;
if (parent != null && parent !== ethnicityId && this.ethnicities.has(parent)) return parent;
let nearest = null;
let nearestDistance = Infinity;
for (const candidate of this.ethnicities.values()) {
if (candidate.id === ethnicityId || (candidate.population || 0) <= 0) continue;
const distance = Math.hypot((candidate.centroidX || 0) - (ethnicity.centroidX || 0), (candidate.centroidY || 0) - (ethnicity.centroidY || 0));
if (distance < nearestDistance) { nearest = candidate.id; nearestDistance = distance; }
}
return nearest;
}
absorbEthnicityInto(ethnicityId, targetId) {
if (ethnicityId === targetId || !this.ethnicities.has(ethnicityId) || !this.ethnicities.has(targetId)) return false;
for (const [tile, mix] of this.tileEthnicMix) {
const amount = mix.get(ethnicityId) || 0;
if (amount <= 0) continue;
mix.delete(ethnicityId);
mix.set(targetId, (mix.get(targetId) || 0) + amount);
this.updateCultureTile(tile);
}
for (const city of this.cities) {
const amount = city.ethnicityComposition?.get(ethnicityId) || 0;
if (amount <= 0) continue;
city.ethnicityComposition.delete(ethnicityId);
city.ethnicityComposition.set(targetId, (city.ethnicityComposition.get(targetId) || 0) + amount);
}
for (const band of this.nomadBands || []) if (band.ethnicityId === ethnicityId) band.ethnicityId = targetId;
for (const ethnicity of this.ethnicities.values()) if (ethnicity.parent === ethnicityId) ethnicity.parent = targetId;
this.ethnicities.delete(ethnicityId);
return true;
}
absorbSmallEthnicities() {
const threshold = Math.max(0, SimConfig.culture?.absorptionPopulationThreshold ?? 30);
if (threshold <= 0 || this.ethnicities.size <= 1) return 0;
let absorbed = 0;
const candidates = [...this.ethnicities.values()]
.filter(ethnicity => (ethnicity.population || 0) > 0 && (ethnicity.population || 0) < threshold)
.sort((a, b) => (a.population || 0) - (b.population || 0));
for (const ethnicity of candidates) {
if (!this.ethnicities.has(ethnicity.id)) continue;
const target = this.absorptionTargetForEthnicity(ethnicity.id);
if (target == null) continue;
if (this.absorbEthnicityInto(ethnicity.id, target)) absorbed++;
}
return absorbed;
}
2026-05-13 20:21:58 +09:00
splitDivergentEthnicities() {
2026-09-16 13:51:43 +09:00
this.splitDivergentFieldEthnicities();
}
splitDivergentFieldEthnicities() {
2026-09-16 20:53:17 +09:00
let formed = 0;
const divergenceSpeed = clamp(SimConfig.culture?.divergenceRateMultiplier ?? 0.25, 0.05, 1);
const divergenceChance = 0.32;
2026-09-16 13:51:43 +09:00
for (const e of [...this.ethnicities.values()]) {
2026-09-16 20:53:17 +09:00
if (formed >= 1) break;
if (this.year < (e.nextDivergenceCheck ?? 0)) continue;
e.nextDivergenceCheck = this.year + years(this.rng.range(24, 90) / divergenceSpeed);
2026-09-16 13:51:43 +09:00
if (e.population < 120 || e.diversity < 0.24) continue;
2026-05-13 20:21:58 +09:00
const candidates = [];
2026-09-16 13:51:43 +09:00
let population = 0;
2026-05-13 20:21:58 +09:00
let tempSum = 0;
let humidSum = 0;
2026-09-16 13:51:43 +09:00
for (const [tile, mix] of this.tileEthnicMix) {
const count = mix.get(e.id) || 0;
if (count < 4) continue;
const x = tile % this.world.size;
const y = Math.floor(tile / this.world.size);
const spatial = Math.hypot(x - e.centroidX, y - e.centroidY) > this.world.size * 0.11;
const climate = this.climateMismatch(e.id, tile) > 0.20;
2026-09-16 20:53:17 +09:00
if ((spatial || climate || this.world.cultureDiversity[tile] > 0.38) && this.rng.next() < divergenceChance) {
2026-09-16 13:51:43 +09:00
candidates.push({ tile, count });
population += count;
tempSum += this.world.temperature[tile] * count;
humidSum += this.world.humidity[tile] * count;
}
}
if (population < 35 || candidates.length < 3) continue;
2026-05-13 20:21:58 +09:00
const newId = this.createEthnicity(e.id, {
2026-09-16 13:51:43 +09:00
temperature: tempSum / population,
humidity: humidSum / population
2026-05-13 20:21:58 +09:00
});
2026-09-16 13:51:43 +09:00
const parentTraits = e.averageTraits || this.randomTraits();
const newTraits = mutateTraits(parentTraits, this.rng, 0.12);
const lineage = this.ethnicities.get(newId);
if (lineage) lineage.averageTraits = newTraits;
for (const { tile, count } of candidates) {
const mix = this.tileEthnicMix.get(tile);
if (!mix) continue;
const split = count * this.rng.range(0.35, 0.68);
mix.set(e.id, Math.max(0, count - split));
mix.set(newId, (mix.get(newId) || 0) + split);
this.updateCultureTile(tile);
2026-05-13 20:21:58 +09:00
}
2026-09-16 20:53:17 +09:00
formed++;
2026-05-13 20:21:58 +09:00
}
}
}