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);
|
|
|
|
|
this.pressure = new Float32Array(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);
|
|
|
|
|
this.polity = new Int32Array(this.count);
|
|
|
|
|
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-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-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;
|
|
|
|
|
this.fertility[i] = clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1);
|
|
|
|
|
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);
|
|
|
|
|
for (let y = 0; y < s; y++) {
|
|
|
|
|
for (let x = 0; x < s; x++) {
|
|
|
|
|
const i = this.idx(x, y);
|
|
|
|
|
if (this.terrain[i] === Terrain.WATER) continue;
|
|
|
|
|
let waterScore = 0;
|
|
|
|
|
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;
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
|
|
|
|
|
if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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]];
|
|
|
|
|
this.fertility[i] = nextFertility[i];
|
|
|
|
|
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
|
|
|
|
|
this.move[i] = info.move;
|
|
|
|
|
if (this.terrain[i] === Terrain.WATER) {
|
|
|
|
|
this.resource[i] = 0;
|
|
|
|
|
} else {
|
|
|
|
|
this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
for (let y = 0; y < s; y++) {
|
|
|
|
|
for (let x = 0; x < s; x++) {
|
|
|
|
|
const i = this.idx(x, y);
|
|
|
|
|
if (this.terrain[i] === Terrain.WATER) {
|
|
|
|
|
this.humidity[i] = 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let best = 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 >= s || ty >= s) continue;
|
|
|
|
|
if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.humidity[i] = clamp(best * 0.78 + moistureMap[i] * 0.22, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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-05-14 14:38:34 +09:00
|
|
|
constructor(size, initialAgents) {
|
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.agents = [];
|
|
|
|
|
this.ethnicities = new Map();
|
|
|
|
|
this.cities = [];
|
|
|
|
|
this.cityById = new Map();
|
|
|
|
|
this.cityBuckets = new Map();
|
|
|
|
|
this.polities = [];
|
|
|
|
|
this.polityById = new Map();
|
|
|
|
|
this.wars = [];
|
|
|
|
|
this.disasters = [];
|
|
|
|
|
this.disasterHistory = [];
|
2026-05-14 14:38:34 +09:00
|
|
|
this.graphEvents = [];
|
2026-05-13 20:21:58 +09:00
|
|
|
this.polityHistory = new Map();
|
|
|
|
|
this.deadPolityHistories = [];
|
|
|
|
|
this.tradeLinks = [];
|
|
|
|
|
this.activeTradeRouteTiles = new Set();
|
|
|
|
|
this.ethnicDensityCache = new Map();
|
|
|
|
|
this.dominantEthnicityCache = new Map();
|
|
|
|
|
this.nextEthnicity = 1;
|
|
|
|
|
this.nextCity = 1;
|
|
|
|
|
this.nextPolity = 1;
|
|
|
|
|
this.nextWar = 1;
|
|
|
|
|
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();
|
|
|
|
|
this.populationFieldInitialized = false;
|
2026-05-13 20:21:58 +09:00
|
|
|
this.maxAgents = Math.max(
|
|
|
|
|
Math.floor(initialAgents * SimConfig.population.maxAgentsScale),
|
|
|
|
|
SimConfig.population.maxAgentsFloor
|
|
|
|
|
);
|
2026-05-17 21:08:03 +09:00
|
|
|
this.tileEthnicMix = new Map();
|
2026-05-13 20:21:58 +09:00
|
|
|
this.tileEthnicities = new Map();
|
2026-05-17 21:08:03 +09:00
|
|
|
this.tileCultures = this.tileEthnicMix;
|
2026-05-13 20:21:58 +09:00
|
|
|
this.tileAgents = new Map();
|
|
|
|
|
this.spawnInitialAgents(initialAgents);
|
2026-05-17 21:08:03 +09:00
|
|
|
this.seedInitialPopulationField();
|
|
|
|
|
this.recomputeTerritories();
|
2026-05-13 20:21:58 +09:00
|
|
|
this.updateEthnicStats();
|
|
|
|
|
}
|
|
|
|
|
spawnInitialAgents(count) {
|
|
|
|
|
const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
|
|
|
|
|
const desertFounders = Math.max(2, Math.floor(founders * 0.25));
|
|
|
|
|
for (let e = 0; e < founders; e++) {
|
|
|
|
|
const desertFounder = e < desertFounders;
|
|
|
|
|
const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null);
|
|
|
|
|
const originTile = this.world.idx(origin.x, origin.y);
|
|
|
|
|
const id = this.createEthnicity(0, {
|
|
|
|
|
temperature: this.world.temperature[originTile],
|
|
|
|
|
humidity: this.world.humidity[originTile]
|
|
|
|
|
});
|
|
|
|
|
const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits();
|
|
|
|
|
for (let n = 0; n < Math.floor(count / founders); n++) {
|
|
|
|
|
const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null);
|
|
|
|
|
this.agents.push(this.makeAgent(
|
|
|
|
|
spawn.x,
|
|
|
|
|
spawn.y,
|
|
|
|
|
id,
|
|
|
|
|
mutateTraits(baseTraits, this.rng, 0.07),
|
|
|
|
|
desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
centroidY: 0
|
|
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
makeAgent(x, y, ethnicity, traits, resources) {
|
|
|
|
|
return {
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
resources,
|
|
|
|
|
ethnicity,
|
|
|
|
|
traits,
|
|
|
|
|
alive: true,
|
|
|
|
|
foreignContact: 0,
|
|
|
|
|
contactEthnicity: ethnicity,
|
|
|
|
|
settled: 0,
|
|
|
|
|
movedThisStep: false,
|
|
|
|
|
tech: {
|
|
|
|
|
farming: 0,
|
|
|
|
|
metallurgy: 0
|
|
|
|
|
},
|
|
|
|
|
farmingWork: 0,
|
2026-05-14 00:21:24 +09:00
|
|
|
lastFarmTile: -1,
|
|
|
|
|
tradeOriginCityId: null,
|
|
|
|
|
lastTradeCityId: null,
|
|
|
|
|
tradeMemory: 0,
|
|
|
|
|
tradeCooldown: 0
|
2026-05-13 20:21:58 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
findNearbySpawn(originX, originY, preferredTerrain = null) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
let best = { x: originX, y: originY };
|
|
|
|
|
let bestScore = -Infinity;
|
|
|
|
|
for (let tries = 0; tries < 24; tries++) {
|
|
|
|
|
const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1);
|
|
|
|
|
const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1);
|
|
|
|
|
const i = w.idx(x, y);
|
|
|
|
|
if (w.terrain[i] === Terrain.WATER) continue;
|
|
|
|
|
const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0;
|
|
|
|
|
const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16;
|
|
|
|
|
if (score > bestScore) {
|
|
|
|
|
bestScore = score;
|
|
|
|
|
best = { x, y };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
const populationLimit = this.maxAgents * (cfg.maxPopulationRatio ?? 1.28);
|
|
|
|
|
if (this.agents.length >= populationLimit) return;
|
|
|
|
|
const side = this.rng.int(4);
|
|
|
|
|
const targetCount = this.rng.int(Math.max(1, (cfg.maxAgents ?? 140) - (cfg.minAgents ?? 60) + 1)) + (cfg.minAgents ?? 60);
|
|
|
|
|
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]
|
|
|
|
|
});
|
|
|
|
|
const baseTraits = this.randomTraits();
|
|
|
|
|
baseTraits.mobility = this.rng.range(0.65, 1.0);
|
|
|
|
|
baseTraits.sedentary = this.rng.range(0.02, 0.35);
|
|
|
|
|
baseTraits.ethnocentrism = this.rng.range(0.42, 0.95);
|
|
|
|
|
baseTraits.assimilation = this.rng.range(0.02, 0.22);
|
|
|
|
|
baseTraits.resourceAttraction = this.rng.range(0.78, 1.15);
|
|
|
|
|
let spawned = 0;
|
|
|
|
|
for (let n = 0; n < targetCount && this.agents.length < populationLimit; n++) {
|
|
|
|
|
const spawn = this.frontierSpawnTile(side, cfg.edgeBand ?? 4);
|
|
|
|
|
const tile = this.world.idx(spawn.x, spawn.y);
|
|
|
|
|
if (this.world.terrain[tile] === Terrain.WATER) continue;
|
|
|
|
|
const agent = this.makeAgent(
|
|
|
|
|
spawn.x,
|
|
|
|
|
spawn.y,
|
|
|
|
|
ethnicity,
|
|
|
|
|
mutateTraits(baseTraits, this.rng, cfg.mutation ?? 0.08),
|
|
|
|
|
this.rng.range(cfg.minResources ?? 12, cfg.maxResources ?? 28)
|
|
|
|
|
);
|
|
|
|
|
agent.tradeMemory = this.rng.range(0.05, 0.22);
|
|
|
|
|
this.agents.push(agent);
|
|
|
|
|
if (this.addAgentToOccupancy) this.addAgentToOccupancy(agent);
|
|
|
|
|
spawned++;
|
|
|
|
|
}
|
|
|
|
|
if (spawned <= 0) this.ethnicities.delete(ethnicity);
|
|
|
|
|
else this.addGraphEvent("newEthnicity", this.year, {
|
|
|
|
|
ethnicity,
|
|
|
|
|
population: spawned
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
step() {
|
|
|
|
|
for (const city of this.cities) city.activeVisitors = 0;
|
|
|
|
|
this.rebuildOccupancy();
|
|
|
|
|
this.ethnicDensityCache.clear();
|
|
|
|
|
const offspring = [];
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
if (!a.alive) continue;
|
|
|
|
|
this.moveAgent(a);
|
|
|
|
|
}
|
|
|
|
|
this.rebuildOccupancy();
|
|
|
|
|
this.dominantEthnicityCache.clear();
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
if (!a.alive) continue;
|
|
|
|
|
this.updateAgentTechnology(a);
|
2026-05-14 00:21:24 +09:00
|
|
|
this.updateAgentTrade(a);
|
2026-05-13 20:21:58 +09:00
|
|
|
this.gatherConsumeReproduce(a, offspring);
|
|
|
|
|
if (a.alive) this.payTechnologyCostOrForget(a);
|
|
|
|
|
if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a);
|
|
|
|
|
}
|
|
|
|
|
this.agents = this.agents.filter(a => a.alive);
|
2026-05-14 14:38:34 +09:00
|
|
|
const acceptedOffspring = [];
|
|
|
|
|
const softCap = Math.max(1, this.maxAgents);
|
|
|
|
|
const hardCap = Math.max(softCap, Math.floor(softCap * (SimConfig.population.hardCapScale ?? 1.35)));
|
|
|
|
|
const softCapStart = SimConfig.population.softCapStart ?? 0.85;
|
|
|
|
|
const crowdingPenalty = SimConfig.population.offspringCrowdingPenalty ?? 1.8;
|
|
|
|
|
const minAcceptance = SimConfig.population.minOffspringAcceptance ?? 0.035;
|
|
|
|
|
for (const child of offspring) {
|
|
|
|
|
const projectedPopulation = this.agents.length + acceptedOffspring.length;
|
|
|
|
|
if (projectedPopulation >= hardCap) break;
|
|
|
|
|
const pressure = projectedPopulation / softCap;
|
|
|
|
|
const overSoftCap = Math.max(0, pressure - softCapStart);
|
|
|
|
|
const acceptChance = clamp(1 - overSoftCap * crowdingPenalty, minAcceptance, 1);
|
|
|
|
|
if (pressure <= softCapStart || this.rng.next() < acceptChance) acceptedOffspring.push(child);
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
this.agents.push(...acceptedOffspring);
|
|
|
|
|
for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
|
2026-05-17 21:08:03 +09:00
|
|
|
this.updatePopulationCapacity();
|
2026-05-13 20:21:58 +09:00
|
|
|
if (this.year % 2 === 0) this.updateWorldFields(2);
|
|
|
|
|
this.maybeSpawnDisaster();
|
2026-05-14 14:38:34 +09:00
|
|
|
if (this.year % years(1) === 0) this.maybeSpawnFrontierWave();
|
2026-05-13 20:21:58 +09:00
|
|
|
if (this.year % years(1) === 0) {
|
2026-05-17 21:08:03 +09:00
|
|
|
this.blendAgentPresenceIntoPopulationField(0.015);
|
2026-05-13 20:21:58 +09:00
|
|
|
this.updateCities();
|
2026-05-17 21:08:03 +09:00
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
this.consumeTileResources();
|
|
|
|
|
this.growTilePopulation();
|
|
|
|
|
this.diffusePopulation();
|
|
|
|
|
if (this.year % years(3) === 0) this.updateTileAssimilation();
|
|
|
|
|
this.updateRegionalCultures();
|
|
|
|
|
this.recomputeTerritories();
|
|
|
|
|
this.campaignPressureOnTerritories();
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
if (this.year % years(5) === 0) {
|
|
|
|
|
this.updateTradeRoutes();
|
|
|
|
|
}
|
|
|
|
|
this.updatePolities();
|
|
|
|
|
this.updateEthnicStats();
|
|
|
|
|
if (this.year % years(45) === 0) {
|
|
|
|
|
this.splitDivergentEthnicities();
|
|
|
|
|
this.updateEthnicStats();
|
|
|
|
|
}
|
|
|
|
|
this.year++;
|
|
|
|
|
}
|
|
|
|
|
rebuildOccupancy() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
w.pressure.fill(0);
|
|
|
|
|
this.tileEthnicities.clear();
|
|
|
|
|
this.tileAgents.clear();
|
|
|
|
|
this.rebuildIndexes();
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
if (!a.alive) continue;
|
|
|
|
|
const i = w.idx(a.x, a.y);
|
|
|
|
|
w.pressure[i]++;
|
|
|
|
|
let agents = this.tileAgents.get(i);
|
|
|
|
|
if (!agents) {
|
|
|
|
|
agents = [];
|
|
|
|
|
this.tileAgents.set(i, agents);
|
|
|
|
|
}
|
|
|
|
|
agents.push(a);
|
|
|
|
|
let counts = this.tileEthnicities.get(i);
|
|
|
|
|
if (!counts) {
|
|
|
|
|
counts = new Map();
|
|
|
|
|
this.tileEthnicities.set(i, counts);
|
|
|
|
|
}
|
|
|
|
|
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
if (!this.populationFieldInitialized) this.rebuildPopulationFieldsFromAgents();
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
rebuildIndexes() {
|
|
|
|
|
this.cityById = new Map(this.cities.map(city => [city.id, city]));
|
|
|
|
|
this.polityById = new Map(this.polities.map(polity => [polity.id, polity]));
|
|
|
|
|
this.cityBuckets = new Map();
|
|
|
|
|
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) {
|
|
|
|
|
return `${Math.floor(x / 8)},${Math.floor(y / 8)}`;
|
|
|
|
|
}
|
|
|
|
|
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++) {
|
|
|
|
|
const bucket = this.cityBuckets.get(`${bx},${by}`);
|
|
|
|
|
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-05-17 21:08:03 +09:00
|
|
|
// Phase 2: transitional tile-level population and culture fields.
|
|
|
|
|
// Agents and cities still exist, but render/culture/state logic can read the
|
|
|
|
|
// aggregate population field and per-tile ethnic composition.
|
|
|
|
|
seedInitialPopulationField() {
|
|
|
|
|
this.rebuildOccupancy();
|
|
|
|
|
this.rebuildPopulationFieldsFromAgents(true);
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
this.populationFieldInitialized = true;
|
|
|
|
|
}
|
|
|
|
|
rebuildPopulationFieldsFromAgents(force = false) {
|
|
|
|
|
if (this.populationFieldInitialized && !force) return;
|
|
|
|
|
const w = this.world;
|
|
|
|
|
w.population.fill(0);
|
|
|
|
|
w.settledPopulation.fill(0);
|
|
|
|
|
w.mobilePopulation.fill(0);
|
|
|
|
|
w.populationPressure.fill(0);
|
|
|
|
|
this.tileEthnicMix.clear();
|
|
|
|
|
this.tileCultures = this.tileEthnicMix;
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
if (!a.alive) continue;
|
|
|
|
|
const tile = w.idx(a.x, a.y);
|
|
|
|
|
const sedentary = clamp(a.traits?.sedentary ?? 0.5, 0, 1);
|
|
|
|
|
w.population[tile] += 1;
|
|
|
|
|
if (sedentary >= 0.55) w.settledPopulation[tile] += 1;
|
|
|
|
|
else w.mobilePopulation[tile] += 1;
|
|
|
|
|
let mix = this.tileEthnicMix.get(tile);
|
|
|
|
|
if (!mix) {
|
|
|
|
|
mix = new Map();
|
|
|
|
|
this.tileEthnicMix.set(tile, mix);
|
|
|
|
|
}
|
|
|
|
|
mix.set(a.ethnicity, (mix.get(a.ethnicity) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
for (const city of this.cities) this.projectCityPopulationToTiles(city);
|
|
|
|
|
this.syncTilePopulationCulture();
|
|
|
|
|
}
|
|
|
|
|
syncTilePopulationCulture() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
this.tileCultures = this.tileEthnicMix;
|
|
|
|
|
for (const tile of this.tileEthnicMix.keys()) this.updateCultureTile(tile);
|
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
|
|
|
if (!this.tileEthnicMix.has(i) && w.population[i] <= 0) {
|
|
|
|
|
w.dominantEthnicity[i] = -1;
|
|
|
|
|
w.cultureDiversity[i] *= 0.985;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
}
|
|
|
|
|
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 = [];
|
|
|
|
|
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) continue;
|
|
|
|
|
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;
|
|
|
|
|
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-05-17 21:08:03 +09:00
|
|
|
const counts = this.tileEthnicMix.get(tile) || this.tileEthnicities.get(tile);
|
2026-05-13 20:21:58 +09:00
|
|
|
if (!counts || !counts.size) {
|
|
|
|
|
this.world.cultureDiversity[tile] *= 0.98;
|
|
|
|
|
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-05-17 21:08:03 +09:00
|
|
|
diffusePopulation() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
const moves = [];
|
|
|
|
|
for (let tile = 0; tile < w.count; tile++) {
|
|
|
|
|
const population = w.population[tile];
|
|
|
|
|
if (population < 1 || w.populationPressure[tile] <= 1.05) continue;
|
|
|
|
|
const x = tile % w.size;
|
|
|
|
|
const y = Math.floor(tile / w.size);
|
|
|
|
|
let bestTile = -1;
|
|
|
|
|
let bestScore = -Infinity;
|
|
|
|
|
const radius = 2;
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
const distance = Math.abs(dx) + Math.abs(dy);
|
|
|
|
|
if (!distance || distance > radius) continue;
|
|
|
|
|
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]);
|
|
|
|
|
const amount = Math.min(population * 0.035, excess * 0.18);
|
|
|
|
|
if (amount >= 0.05) moves.push({ from: tile, to: bestTile, amount });
|
|
|
|
|
}
|
|
|
|
|
for (const move of moves) this.moveEthnicPopulation(move.from, move.to, move.amount);
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
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);
|
|
|
|
|
const foreignPolity = w.polity[toTile] >= 0 && w.polity[fromTile] >= 0 && w.polity[toTile] !== w.polity[fromTile] ? 1.25 : 0;
|
|
|
|
|
return w.resource[toTile] * 0.06 +
|
|
|
|
|
w.fertility[toTile] * 1.4 +
|
|
|
|
|
w.mineral[toTile] * 0.35 +
|
|
|
|
|
w.cityPull[toTile] * 1.3 +
|
|
|
|
|
routeScore +
|
|
|
|
|
sameEthnicity -
|
|
|
|
|
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;
|
|
|
|
|
this.updateCultureTile(fromTile);
|
|
|
|
|
this.updateCultureTile(toTile);
|
|
|
|
|
return moved;
|
|
|
|
|
}
|
|
|
|
|
growTilePopulation() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
for (let tile = 0; tile < w.count; tile++) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
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;
|
|
|
|
|
this.updateCultureTile(tile);
|
|
|
|
|
}
|
|
|
|
|
removeEthnicPopulationProportionally(tile, amount) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const mix = this.tileEthnicMix.get(tile);
|
|
|
|
|
if (!mix) return;
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
consumeTileResources() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (let tile = 0; tile < w.count; tile++) {
|
|
|
|
|
const population = w.population[tile];
|
|
|
|
|
if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue;
|
|
|
|
|
const pressure = w.populationPressure[tile] || 0;
|
|
|
|
|
const demand = population * (0.0045 + Math.max(0, pressure - 1) * 0.0035);
|
|
|
|
|
w.resource[tile] = Math.max(0, w.resource[tile] - demand);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
blendAgentPresenceIntoPopulationField(weight = 0.01) {
|
|
|
|
|
if (!this.populationFieldInitialized || weight <= 0 || !this.agents.length) return;
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
if (!a.alive) continue;
|
|
|
|
|
const tile = w.idx(a.x, a.y);
|
|
|
|
|
const amount = weight;
|
|
|
|
|
w.population[tile] += amount;
|
|
|
|
|
if ((a.traits?.sedentary ?? 0.5) >= 0.55) w.settledPopulation[tile] += amount;
|
|
|
|
|
else w.mobilePopulation[tile] += amount;
|
|
|
|
|
let mix = this.tileEthnicMix.get(tile);
|
|
|
|
|
if (!mix) {
|
|
|
|
|
mix = new Map();
|
|
|
|
|
this.tileEthnicMix.set(tile, mix);
|
|
|
|
|
}
|
|
|
|
|
mix.set(a.ethnicity, (mix.get(a.ethnicity) || 0) + amount);
|
|
|
|
|
this.updateCultureTile(tile);
|
|
|
|
|
}
|
|
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
}
|
|
|
|
|
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-05-13 20:21:58 +09:00
|
|
|
updateRegionalCultures() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const nextDominant = new Int32Array(w.dominantEthnicity);
|
|
|
|
|
const nextDiversity = new Float32Array(w.cultureDiversity);
|
|
|
|
|
const radius = SimConfig.culture.spreadRadius;
|
|
|
|
|
const cityById = new Map(this.cities.map(city => [city.id, city]));
|
|
|
|
|
for (let y = 0; y < w.size; y++) {
|
|
|
|
|
for (let x = 0; x < w.size; x++) {
|
|
|
|
|
const tile = w.idx(x, y);
|
|
|
|
|
if (w.terrain[tile] === Terrain.WATER) continue;
|
|
|
|
|
const influence = new Map();
|
|
|
|
|
let total = 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 > radius) continue;
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
|
|
|
|
const source = w.idx(tx, ty);
|
|
|
|
|
const id = w.dominantEthnicity[source];
|
|
|
|
|
if (id < 0) continue;
|
2026-05-17 21:08:03 +09:00
|
|
|
const pressureWeight = Math.sqrt(Math.max(0, w.population[source]));
|
2026-05-13 20:21:58 +09:00
|
|
|
const cityWeight = w.city[source] >= 0
|
|
|
|
|
? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight
|
|
|
|
|
: 0;
|
|
|
|
|
const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1;
|
|
|
|
|
const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance);
|
|
|
|
|
if (weight <= 0) continue;
|
|
|
|
|
influence.set(id, (influence.get(id) || 0) + weight);
|
|
|
|
|
total += weight;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let bestId = nextDominant[tile];
|
|
|
|
|
let bestWeight = 0;
|
|
|
|
|
for (const [id, weight] of influence) {
|
|
|
|
|
if (weight > bestWeight) {
|
|
|
|
|
bestId = id;
|
|
|
|
|
bestWeight = weight;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bestWeight >= SimConfig.culture.minimumInfluence) {
|
|
|
|
|
nextDominant[tile] = bestId;
|
|
|
|
|
nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
w.dominantEthnicity.set(nextDominant);
|
|
|
|
|
w.cultureDiversity.set(nextDiversity);
|
|
|
|
|
}
|
|
|
|
|
addAgentToOccupancy(a) {
|
|
|
|
|
if (!a.alive) return;
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const i = w.idx(a.x, a.y);
|
|
|
|
|
w.pressure[i]++;
|
2026-05-17 21:08:03 +09:00
|
|
|
if (!this.populationFieldInitialized) w.population[i]++;
|
2026-05-13 20:21:58 +09:00
|
|
|
let agents = this.tileAgents.get(i);
|
|
|
|
|
if (!agents) {
|
|
|
|
|
agents = [];
|
|
|
|
|
this.tileAgents.set(i, agents);
|
|
|
|
|
}
|
|
|
|
|
agents.push(a);
|
|
|
|
|
let counts = this.tileEthnicities.get(i);
|
|
|
|
|
if (!counts) {
|
|
|
|
|
counts = new Map();
|
|
|
|
|
this.tileEthnicities.set(i, counts);
|
|
|
|
|
}
|
|
|
|
|
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
|
2026-05-17 21:08:03 +09:00
|
|
|
let culture = this.tileEthnicMix.get(i);
|
|
|
|
|
if (!this.populationFieldInitialized) {
|
|
|
|
|
if (!culture) {
|
|
|
|
|
culture = new Map();
|
|
|
|
|
this.tileEthnicMix.set(i, culture);
|
|
|
|
|
}
|
|
|
|
|
culture.set(a.ethnicity, (culture.get(a.ethnicity) || 0) + 1);
|
2026-05-13 20:21:58 +09:00
|
|
|
this.updateCultureTile(i);
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
removeAgentFromOccupancy(a) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const i = w.idx(a.x, a.y);
|
|
|
|
|
w.pressure[i] = Math.max(0, w.pressure[i] - 1);
|
2026-05-17 21:08:03 +09:00
|
|
|
if (!this.populationFieldInitialized) w.population[i] = Math.max(0, w.population[i] - 1);
|
2026-05-13 20:21:58 +09:00
|
|
|
const agents = this.tileAgents.get(i);
|
|
|
|
|
if (agents) {
|
|
|
|
|
const index = agents.indexOf(a);
|
|
|
|
|
if (index >= 0) agents.splice(index, 1);
|
|
|
|
|
if (!agents.length) this.tileAgents.delete(i);
|
|
|
|
|
}
|
|
|
|
|
const counts = this.tileEthnicities.get(i);
|
|
|
|
|
if (counts) {
|
|
|
|
|
const next = (counts.get(a.ethnicity) || 0) - 1;
|
|
|
|
|
if (next > 0) counts.set(a.ethnicity, next);
|
|
|
|
|
else counts.delete(a.ethnicity);
|
|
|
|
|
if (!counts.size) this.tileEthnicities.delete(i);
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
const culture = this.tileEthnicMix.get(i);
|
|
|
|
|
if (!this.populationFieldInitialized) {
|
|
|
|
|
if (culture) {
|
|
|
|
|
const next = (culture.get(a.ethnicity) || 0) - 1;
|
|
|
|
|
if (next > 0) culture.set(a.ethnicity, next);
|
|
|
|
|
else culture.delete(a.ethnicity);
|
|
|
|
|
if (!culture.size) this.tileEthnicMix.delete(i);
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
this.updateCultureTile(i);
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
changeAgentEthnicity(a, nextEthnicity) {
|
|
|
|
|
if (a.ethnicity === nextEthnicity) return;
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const i = w.idx(a.x, a.y);
|
|
|
|
|
const counts = this.tileEthnicities.get(i);
|
|
|
|
|
if (counts) {
|
|
|
|
|
const prev = (counts.get(a.ethnicity) || 0) - 1;
|
|
|
|
|
if (prev > 0) counts.set(a.ethnicity, prev);
|
|
|
|
|
else counts.delete(a.ethnicity);
|
|
|
|
|
counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1);
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
const culture = this.tileEthnicMix.get(i);
|
|
|
|
|
if (!this.populationFieldInitialized) {
|
|
|
|
|
if (culture) {
|
|
|
|
|
const prev = (culture.get(a.ethnicity) || 0) - 1;
|
|
|
|
|
if (prev > 0) culture.set(a.ethnicity, prev);
|
|
|
|
|
else culture.delete(a.ethnicity);
|
|
|
|
|
culture.set(nextEthnicity, (culture.get(nextEthnicity) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
a.ethnicity = nextEthnicity;
|
|
|
|
|
this.updateCultureTile(i);
|
|
|
|
|
}
|
|
|
|
|
moveAgent(a) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const s = w.size;
|
|
|
|
|
const current = w.idx(a.x, a.y);
|
|
|
|
|
const localPressure = w.pressure[current];
|
|
|
|
|
let bestX = a.x;
|
|
|
|
|
let bestY = a.y;
|
|
|
|
|
let bestScore = -Infinity;
|
2026-05-14 14:38:34 +09:00
|
|
|
const sedentary = a.traits.sedentary;
|
|
|
|
|
const ethnocentrism = a.traits.ethnocentrism;
|
2026-05-13 20:21:58 +09:00
|
|
|
const ethnicClimate = this.ethnicities.get(a.ethnicity);
|
|
|
|
|
const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
|
|
|
|
|
const localCapacity = this.carryingCapacityAt(current);
|
|
|
|
|
const localOverCapacity = Math.max(0, localPressure - localCapacity);
|
|
|
|
|
if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) {
|
|
|
|
|
a.settled++;
|
|
|
|
|
a.movedThisStep = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1;
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
const x = clamp(a.x + dx, 0, s - 1);
|
|
|
|
|
const y = clamp(a.y + dy, 0, s - 1);
|
|
|
|
|
const i = w.idx(x, y);
|
|
|
|
|
const isWater = w.terrain[i] === Terrain.WATER;
|
|
|
|
|
const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45;
|
|
|
|
|
const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0);
|
|
|
|
|
if (isWater && this.rng.next() > waterAccess) continue;
|
|
|
|
|
const ethnicDensity = ethnocentrism > 0.04
|
|
|
|
|
? this.ethnicDensityNear(x, y, a.ethnicity)
|
|
|
|
|
: { same: 0, foreign: 0 };
|
|
|
|
|
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
|
2026-05-14 00:21:24 +09:00
|
|
|
const routeStrength = clamp(w.tradeRoute[i] / 80, 0, 1.8);
|
|
|
|
|
const routePull = (w.tradeRoute[i] ? 1.55 + routeStrength * 0.55 : clamp(w.pheromone[i] / 8, 0, 1)) * (0.35 + a.traits.mobility * 0.55 + sedentary * 0.45);
|
2026-05-13 20:21:58 +09:00
|
|
|
const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
|
|
|
|
|
const destinationCapacity = this.carryingCapacityAt(i);
|
|
|
|
|
const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity);
|
|
|
|
|
const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1);
|
|
|
|
|
const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85);
|
2026-05-14 00:21:24 +09:00
|
|
|
const routeBonus = w.tradeRoute[i] ? 1.35 + routeStrength * 0.45 : 0;
|
2026-05-13 20:21:58 +09:00
|
|
|
const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0;
|
|
|
|
|
const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
|
|
|
|
|
const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
|
|
|
|
|
const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0;
|
|
|
|
|
const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45);
|
|
|
|
|
const score =
|
|
|
|
|
resourceScore * a.traits.resourceAttraction +
|
|
|
|
|
w.pheromone[i] * 0.021 +
|
|
|
|
|
ethnicDensity.same * ethnocentrism +
|
|
|
|
|
cityPull * sedentary +
|
|
|
|
|
routePull +
|
|
|
|
|
capacityPull +
|
|
|
|
|
inertia -
|
|
|
|
|
terrainPenalty -
|
|
|
|
|
crowdPenalty +
|
|
|
|
|
pressurePush -
|
|
|
|
|
ethnicDensity.foreign * ethnocentrism * 0.72 +
|
|
|
|
|
this.rng.range(-0.55, 0.55);
|
|
|
|
|
if (score > bestScore) {
|
|
|
|
|
bestScore = score;
|
|
|
|
|
bestX = x;
|
|
|
|
|
bestY = y;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const from = w.idx(a.x, a.y);
|
|
|
|
|
a.x = bestX;
|
|
|
|
|
a.y = bestY;
|
|
|
|
|
const to = w.idx(a.x, a.y);
|
|
|
|
|
if (from !== to) {
|
|
|
|
|
a.movedThisStep = true;
|
|
|
|
|
a.farmingWork = 0;
|
|
|
|
|
a.lastFarmTile = to;
|
|
|
|
|
const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
|
2026-05-14 00:21:24 +09:00
|
|
|
this.addPheromone(from, (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale);
|
|
|
|
|
this.addPheromone(to, (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale);
|
2026-05-13 20:21:58 +09:00
|
|
|
a.settled = Math.max(0, a.settled - 1);
|
|
|
|
|
} else {
|
|
|
|
|
a.movedThisStep = false;
|
|
|
|
|
a.settled++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ensureAgentTech(a) {
|
|
|
|
|
a.tech ??= { farming: 0, metallurgy: 0 };
|
|
|
|
|
a.tech.farming ??= 0;
|
|
|
|
|
a.tech.metallurgy ??= 0;
|
|
|
|
|
a.tech.farming = clamp(a.tech.farming, 0, 1);
|
|
|
|
|
a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1);
|
|
|
|
|
a.farmingWork ??= 0;
|
|
|
|
|
a.lastFarmTile ??= -1;
|
|
|
|
|
a.movedThisStep ??= false;
|
|
|
|
|
}
|
2026-05-14 00:21:24 +09:00
|
|
|
ensureAgentTrade(agent) {
|
|
|
|
|
agent.tradeOriginCityId ??= null;
|
|
|
|
|
agent.lastTradeCityId ??= null;
|
|
|
|
|
agent.tradeMemory ??= 0;
|
|
|
|
|
agent.tradeCooldown ??= 0;
|
|
|
|
|
agent.tradeMemory = clamp(agent.tradeMemory, 0, 1);
|
|
|
|
|
agent.tradeCooldown = Math.max(0, Math.floor(agent.tradeCooldown));
|
|
|
|
|
}
|
|
|
|
|
agentNomadism(agent) {
|
|
|
|
|
const mobility = clamp(agent.traits?.mobility ?? 0.5, 0, 1);
|
2026-05-14 14:38:34 +09:00
|
|
|
const sedentary = clamp(agent.traits?.sedentary ?? 0.5, 0, 1);
|
2026-05-14 00:21:24 +09:00
|
|
|
return clamp(mobility * 0.75 + (1 - sedentary) * 0.55, 0, 1);
|
|
|
|
|
}
|
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;
|
|
|
|
|
}
|
|
|
|
|
carryingCapacityAt(tile) {
|
2026-05-17 21:08:03 +09:00
|
|
|
return this.carryingCapacityAtTile(tile);
|
|
|
|
|
}
|
|
|
|
|
carryingCapacityAtTile(tile) {
|
2026-05-13 20:21:58 +09:00
|
|
|
const w = this.world;
|
2026-05-17 21:08:03 +09:00
|
|
|
if (w.terrain[tile] === Terrain.WATER) return 0.35;
|
|
|
|
|
const terrainFactor =
|
|
|
|
|
w.terrain[tile] === Terrain.MOUNTAIN ? 0.62 :
|
|
|
|
|
w.terrain[tile] === Terrain.DESERT ? 0.55 :
|
|
|
|
|
w.terrain[tile] === Terrain.FOREST ? 0.92 :
|
|
|
|
|
1;
|
|
|
|
|
const routeBonus = w.tradeRoute[tile] ? 1.6 : clamp(w.pheromone[tile] / Math.max(1, SimConfig.route.maxPheromone), 0, 1) * 0.8;
|
|
|
|
|
const cityBonus = w.city[tile] >= 0 ? 3.5 : w.cityPull[tile] * 2.2;
|
|
|
|
|
return Math.max(0.1, (
|
|
|
|
|
SimConfig.population.carryingCapacityBase +
|
2026-05-13 20:21:58 +09:00
|
|
|
w.fertility[tile] * SimConfig.population.carryingCapacityFertility +
|
|
|
|
|
w.mineral[tile] * SimConfig.population.carryingCapacityMineral +
|
|
|
|
|
w.farmland[tile] * SimConfig.population.carryingCapacityFarmland +
|
2026-05-17 21:08:03 +09:00
|
|
|
routeBonus +
|
|
|
|
|
cityBonus
|
|
|
|
|
) * terrainFactor);
|
|
|
|
|
}
|
|
|
|
|
updatePopulationCapacity() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
|
|
|
const capacity = this.carryingCapacityAtTile(i);
|
|
|
|
|
w.populationCapacity[i] = capacity;
|
|
|
|
|
w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0;
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
updateAgentTechnology(agent, payMaintenance = false) {
|
|
|
|
|
if (!agent.alive) return;
|
|
|
|
|
this.ensureAgentTech(agent);
|
|
|
|
|
this.updateFarmingWork(agent);
|
|
|
|
|
this.tryInventTechnology(agent);
|
|
|
|
|
this.learnTechnologyFromCity(agent);
|
|
|
|
|
if (this.shouldRunAgentSocial(agent, 3)) {
|
|
|
|
|
this.spreadTechnology(agent);
|
|
|
|
|
this.improveTechnologyFromDensity(agent);
|
|
|
|
|
}
|
|
|
|
|
if (payMaintenance) this.payTechnologyCostOrForget(agent);
|
|
|
|
|
}
|
|
|
|
|
shouldRunAgentSocial(agent, interval) {
|
|
|
|
|
return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0;
|
|
|
|
|
}
|
|
|
|
|
updateFarmingWork(agent) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const tile = w.idx(agent.x, agent.y);
|
|
|
|
|
if (agent.lastFarmTile !== tile) {
|
|
|
|
|
agent.farmingWork = 0;
|
|
|
|
|
agent.lastFarmTile = tile;
|
|
|
|
|
}
|
|
|
|
|
if (agent.movedThisStep) return;
|
|
|
|
|
const farming = agent.tech?.farming || 0;
|
|
|
|
|
if (farming <= 0.02) return;
|
|
|
|
|
if (agent.settled > 0) {
|
|
|
|
|
agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1);
|
|
|
|
|
agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tryInventTechnology(agent) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const tile = w.idx(agent.x, agent.y);
|
|
|
|
|
const settledFactor = clamp((agent.settled || 0) / 12, 0, 1);
|
|
|
|
|
if ((agent.tech?.farming || 0) <= 0.02) {
|
|
|
|
|
const farmingBase = 0.00016;
|
|
|
|
|
const farmability = this.farmabilityAt(tile);
|
2026-05-14 14:38:34 +09:00
|
|
|
const sedentary = agent.traits.sedentary;
|
2026-05-13 20:21:58 +09:00
|
|
|
const farmingChance =
|
|
|
|
|
farmingBase *
|
|
|
|
|
(0.25 + farmability) *
|
|
|
|
|
(0.4 + sedentary) *
|
|
|
|
|
(0.3 + settledFactor);
|
|
|
|
|
if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32);
|
|
|
|
|
}
|
|
|
|
|
if ((agent.tech?.metallurgy || 0) <= 0.02) {
|
|
|
|
|
const metallurgyBase = 0.00011;
|
|
|
|
|
const mineral = clamp(w.mineral[tile], 0, 1);
|
|
|
|
|
const mineralTerrainBonus =
|
2026-05-14 20:15:23 +09:00
|
|
|
w.terrain[tile] === Terrain.MOUNTAIN
|
2026-05-13 20:21:58 +09:00
|
|
|
? 1.8
|
|
|
|
|
: 1.0;
|
|
|
|
|
const metallurgyChance =
|
|
|
|
|
metallurgyBase *
|
|
|
|
|
(0.2 + mineral) *
|
|
|
|
|
mineralTerrainBonus *
|
|
|
|
|
(0.5 + settledFactor);
|
|
|
|
|
if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
grantTechnologyAround(agent, techName, amount) {
|
|
|
|
|
this.ensureAgentTech(agent);
|
|
|
|
|
this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
|
|
|
|
|
if (!other.alive) return true;
|
|
|
|
|
this.ensureAgentTech(other);
|
|
|
|
|
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
|
|
|
|
|
if (distance > 2) return true;
|
|
|
|
|
const gain = amount * (other === agent ? 1 : 0.55);
|
|
|
|
|
other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
payTechnologyCostOrForget(agent) {
|
|
|
|
|
if (!agent.alive) return;
|
|
|
|
|
this.ensureAgentTech(agent);
|
|
|
|
|
const farming = agent.tech.farming || 0;
|
|
|
|
|
const metallurgy = agent.tech.metallurgy || 0;
|
|
|
|
|
const cost = farming * 0.004 + metallurgy * 0.010;
|
|
|
|
|
if (cost <= 0) return;
|
|
|
|
|
if (agent.resources >= cost) {
|
|
|
|
|
agent.resources -= cost;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const shortage = cost - Math.max(0, agent.resources);
|
|
|
|
|
agent.resources = Math.max(0, agent.resources - cost);
|
|
|
|
|
const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06);
|
|
|
|
|
agent.tech.farming = Math.max(0, farming - decay);
|
|
|
|
|
agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15);
|
|
|
|
|
if (agent.tech.farming < 0.02) agent.tech.farming = 0;
|
|
|
|
|
if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0;
|
|
|
|
|
if (agent.tech.farming <= 0) agent.farmingWork = 0;
|
|
|
|
|
}
|
|
|
|
|
spreadTechnology(agent) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const tile = w.idx(agent.x, agent.y);
|
|
|
|
|
const assimilation = agent.traits.assimilation || 0;
|
|
|
|
|
const ethnocentrism = agent.traits.ethnocentrism || 0;
|
2026-05-14 00:21:24 +09:00
|
|
|
const nomadism = this.agentNomadism(agent);
|
2026-05-13 20:21:58 +09:00
|
|
|
let checked = 0;
|
|
|
|
|
this.forEachLocalAgentNear(agent.x, agent.y, 1, other => {
|
|
|
|
|
if (other === agent || !other.alive) return true;
|
|
|
|
|
if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true;
|
|
|
|
|
if (++checked > 12) return false;
|
|
|
|
|
this.ensureAgentTech(other);
|
|
|
|
|
const sameEthnicity = agent.ethnicity === other.ethnicity;
|
|
|
|
|
let chance =
|
|
|
|
|
0.0035 +
|
|
|
|
|
assimilation * 0.006 -
|
|
|
|
|
ethnocentrism * 0.0012;
|
|
|
|
|
if (sameEthnicity) chance += 0.004;
|
|
|
|
|
if (w.tradeRoute[tile]) chance += 0.006;
|
|
|
|
|
if (w.city[tile] >= 0) chance += 0.003;
|
2026-05-14 00:21:24 +09:00
|
|
|
chance += nomadism * 0.004;
|
2026-05-13 20:21:58 +09:00
|
|
|
chance = clamp(chance, 0.0008, 0.028);
|
|
|
|
|
this.learnTechnologyFrom(agent, other, "farming", chance);
|
|
|
|
|
this.learnTechnologyFrom(agent, other, "metallurgy", chance);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
learnTechnologyFrom(agent, other, techName, chance) {
|
|
|
|
|
const current = agent.tech[techName] || 0;
|
|
|
|
|
const otherLevel = other.tech?.[techName] || 0;
|
|
|
|
|
if (otherLevel > current + 0.04 && this.rng.next() < chance) {
|
|
|
|
|
agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
learnTechnologyFromCity(agent) {
|
|
|
|
|
const tile = this.world.idx(agent.x, agent.y);
|
|
|
|
|
const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null;
|
|
|
|
|
if (!city?.knowledge) return;
|
|
|
|
|
const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0;
|
|
|
|
|
const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006);
|
|
|
|
|
if (this.rng.next() < chance) {
|
|
|
|
|
agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1);
|
|
|
|
|
agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 00:21:24 +09:00
|
|
|
updateAgentTrade(agent) {
|
|
|
|
|
this.ensureAgentTrade(agent);
|
|
|
|
|
this.ensureAgentTech(agent);
|
|
|
|
|
if (agent.tradeCooldown > 0) {
|
|
|
|
|
agent.tradeCooldown--;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const city = this.encounteredCityForAgent(agent);
|
|
|
|
|
if (!city) return;
|
|
|
|
|
const nomadism = this.agentNomadism(agent);
|
|
|
|
|
let chance =
|
|
|
|
|
0.015 +
|
|
|
|
|
nomadism * 0.08 +
|
|
|
|
|
(agent.tradeMemory || 0) * 0.04;
|
|
|
|
|
if (nomadism < 0.35) chance *= 0.18;
|
|
|
|
|
const tile = this.world.idx(agent.x, agent.y);
|
|
|
|
|
if (this.world.tradeRoute[tile]) chance += 0.04;
|
|
|
|
|
chance += clamp(Math.sqrt(Math.max(0, city.population || 0)) / 80, 0, 0.08);
|
|
|
|
|
chance += ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.015;
|
|
|
|
|
chance = clamp(chance, 0.005, 0.22);
|
|
|
|
|
if (this.rng.next() > chance) return;
|
|
|
|
|
if (agent.tradeOriginCityId === null) {
|
|
|
|
|
agent.tradeOriginCityId = city.id;
|
|
|
|
|
agent.lastTradeCityId = city.id;
|
|
|
|
|
agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.01, 0, 1);
|
|
|
|
|
agent.tradeCooldown = Math.floor(years(1));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (agent.tradeOriginCityId === city.id) {
|
|
|
|
|
agent.lastTradeCityId = city.id;
|
|
|
|
|
agent.tradeCooldown = Math.floor(years(0.5));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const originCity = this.getCityById(agent.tradeOriginCityId);
|
|
|
|
|
if (!originCity) {
|
|
|
|
|
agent.tradeOriginCityId = city.id;
|
|
|
|
|
agent.lastTradeCityId = city.id;
|
|
|
|
|
agent.tradeCooldown = Math.floor(years(1));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.completeAgentTrade(agent, originCity, city);
|
|
|
|
|
}
|
|
|
|
|
encounteredCityForAgent(agent) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const tile = w.idx(agent.x, agent.y);
|
|
|
|
|
if (w.city[tile] >= 0) {
|
|
|
|
|
const city = this.getCityById(w.city[tile]);
|
|
|
|
|
if (city) return city;
|
|
|
|
|
}
|
|
|
|
|
const candidates = this.getCitiesNear(agent.x, agent.y, 4);
|
|
|
|
|
if (!candidates.length) return null;
|
|
|
|
|
candidates.sort((a, b) => {
|
|
|
|
|
const da = Math.abs(a.x - agent.x) + Math.abs(a.y - agent.y);
|
|
|
|
|
const db = Math.abs(b.x - agent.x) + Math.abs(b.y - agent.y);
|
|
|
|
|
if (da !== db) return da - db;
|
|
|
|
|
return (b.population || 0) - (a.population || 0);
|
|
|
|
|
});
|
|
|
|
|
return candidates[0];
|
|
|
|
|
}
|
|
|
|
|
completeAgentTrade(agent, originCity, destinationCity) {
|
|
|
|
|
if (!originCity || !destinationCity || originCity.id === destinationCity.id) return;
|
|
|
|
|
this.ensureAgentTech(agent);
|
|
|
|
|
this.ensureAgentTrade(agent);
|
|
|
|
|
const nomadism = this.agentNomadism(agent);
|
|
|
|
|
const distance = this.distanceBetweenCities(originCity, destinationCity);
|
|
|
|
|
const routeFactor = this.hasDirectTradeConnection(originCity, destinationCity) ? 1.35 : 1.0;
|
|
|
|
|
const distanceFactor = clamp(distance / 28, 0.45, 2.1);
|
|
|
|
|
const cityScale = Math.sqrt(Math.max(1, originCity.population || 1)) *
|
|
|
|
|
Math.sqrt(Math.max(1, destinationCity.population || 1));
|
|
|
|
|
const techFactor = 1 + ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.12;
|
|
|
|
|
const memoryFactor = 1 + (agent.tradeMemory || 0) * 0.35;
|
|
|
|
|
const rawProfit =
|
|
|
|
|
cityScale *
|
|
|
|
|
0.010 *
|
|
|
|
|
distanceFactor *
|
|
|
|
|
routeFactor *
|
|
|
|
|
techFactor *
|
|
|
|
|
memoryFactor *
|
|
|
|
|
clamp(nomadism, 0.25, 1);
|
|
|
|
|
const profit = clamp(rawProfit, 0.25, 7.5);
|
|
|
|
|
agent.resources += profit * 0.50;
|
|
|
|
|
originCity.storedResources += profit * 0.20;
|
|
|
|
|
destinationCity.storedResources += profit * 0.30;
|
|
|
|
|
this.exchangeAgentTradeKnowledge(agent, originCity, destinationCity);
|
|
|
|
|
const currentTile = this.world.idx(agent.x, agent.y);
|
|
|
|
|
this.addPheromone(currentTile, profit * 0.45);
|
|
|
|
|
this.addPheromone(this.world.idx(originCity.x, originCity.y), profit * 0.30);
|
|
|
|
|
this.addPheromone(this.world.idx(destinationCity.x, destinationCity.y), profit * 0.35);
|
|
|
|
|
agent.tradeOriginCityId = destinationCity.id;
|
|
|
|
|
agent.lastTradeCityId = destinationCity.id;
|
|
|
|
|
agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.035, 0, 1);
|
|
|
|
|
agent.tradeCooldown = Math.floor(years(1.5));
|
|
|
|
|
}
|
|
|
|
|
exchangeAgentTradeKnowledge(agent, originCity, destinationCity) {
|
|
|
|
|
if (originCity.knowledge) {
|
|
|
|
|
originCity.knowledge.farming = clamp(Math.max(originCity.knowledge.farming, (agent.tech?.farming || 0) * 0.45), 0, 1);
|
|
|
|
|
originCity.knowledge.metallurgy = clamp(Math.max(originCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.45), 0, 1);
|
|
|
|
|
}
|
|
|
|
|
if (destinationCity.knowledge) {
|
|
|
|
|
destinationCity.knowledge.farming = clamp(Math.max(destinationCity.knowledge.farming, (agent.tech?.farming || 0) * 0.55), 0, 1);
|
|
|
|
|
destinationCity.knowledge.metallurgy = clamp(Math.max(destinationCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.55), 0, 1);
|
|
|
|
|
agent.tech.farming = clamp(Math.max(agent.tech.farming || 0, destinationCity.knowledge.farming * 0.25), 0, 1);
|
|
|
|
|
agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy || 0, destinationCity.knowledge.metallurgy * 0.25), 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
improveTechnologyFromDensity(agent) {
|
|
|
|
|
let farmingCount = 0;
|
|
|
|
|
let metallurgyCount = 0;
|
|
|
|
|
let farmingSum = 0;
|
|
|
|
|
let metallurgySum = 0;
|
|
|
|
|
this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
|
|
|
|
|
if (!other.alive) return true;
|
|
|
|
|
this.ensureAgentTech(other);
|
|
|
|
|
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
|
|
|
|
|
if (distance > 2) return true;
|
|
|
|
|
const farming = other.tech.farming || 0;
|
|
|
|
|
const metallurgy = other.tech.metallurgy || 0;
|
|
|
|
|
if (farming > 0.1) {
|
|
|
|
|
farmingCount++;
|
|
|
|
|
farmingSum += farming;
|
|
|
|
|
}
|
|
|
|
|
if (metallurgy > 0.1) {
|
|
|
|
|
metallurgyCount++;
|
|
|
|
|
metallurgySum += metallurgy;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
if (farmingCount >= 2) {
|
|
|
|
|
const avgFarming = farmingSum / farmingCount;
|
|
|
|
|
agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
if (metallurgyCount >= 2) {
|
|
|
|
|
const avgMetallurgy = metallurgySum / metallurgyCount;
|
|
|
|
|
agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
forEachLocalAgentNear(x, y, radius, callback) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
|
|
|
|
const agents = this.tileAgents.get(w.idx(tx, ty));
|
|
|
|
|
if (!agents) continue;
|
|
|
|
|
for (const agent of agents) {
|
|
|
|
|
if (callback(agent) === false) return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
farmingGatherMultiplier(agent, tile) {
|
|
|
|
|
const farmingLevel = agent.tech?.farming || 0;
|
|
|
|
|
const farmingWork = agent.farmingWork || 0;
|
|
|
|
|
const farmability = this.farmabilityAt(tile);
|
|
|
|
|
return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3);
|
|
|
|
|
}
|
|
|
|
|
metallurgyGatherMultiplier(agent, tile) {
|
|
|
|
|
const metallurgyLevel = agent.tech?.metallurgy || 0;
|
|
|
|
|
if (metallurgyLevel > 0.02) {
|
|
|
|
|
agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1);
|
|
|
|
|
}
|
|
|
|
|
gatherConsumeReproduce(a, offspring) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const i = w.idx(a.x, a.y);
|
|
|
|
|
this.ensureAgentTech(a);
|
|
|
|
|
const ethnicity = this.ethnicities.get(a.ethnicity);
|
|
|
|
|
const climateMismatch = this.climateMismatch(a.ethnicity, i);
|
|
|
|
|
const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1);
|
|
|
|
|
const cityMarket = w.city[i] >= 0 ? 0.28 : 0;
|
|
|
|
|
const drylandAdapted = this.isDrylandAdapted(ethnicity);
|
|
|
|
|
const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0;
|
|
|
|
|
const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage;
|
|
|
|
|
const carryingCapacity = this.carryingCapacityAt(i);
|
|
|
|
|
const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity);
|
|
|
|
|
const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase);
|
|
|
|
|
const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2);
|
|
|
|
|
const gathered = Math.min(
|
|
|
|
|
w.resource[i],
|
|
|
|
|
baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i)
|
|
|
|
|
);
|
|
|
|
|
w.resource[i] -= gathered;
|
|
|
|
|
a.resources += gathered;
|
|
|
|
|
const waterAdaptation = ethnicity?.climateHumidity ?? 0.45;
|
|
|
|
|
const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0;
|
|
|
|
|
const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4;
|
|
|
|
|
const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1;
|
2026-05-14 14:38:34 +09:00
|
|
|
const sedentary = a.traits.sedentary;
|
2026-05-13 20:21:58 +09:00
|
|
|
const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08;
|
|
|
|
|
a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead;
|
|
|
|
|
if (a.resources <= 0) {
|
|
|
|
|
this.removeAgentFromOccupancy(a);
|
|
|
|
|
a.alive = false;
|
|
|
|
|
this.deaths++;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
|
|
|
|
|
const mobilityPenalty = (1 - sedentary) * 0.45;
|
|
|
|
|
const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty;
|
|
|
|
|
const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35);
|
|
|
|
|
if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) {
|
|
|
|
|
const childShare = 0.36 + sedentary * 0.08;
|
|
|
|
|
const childResources = a.resources * childShare;
|
|
|
|
|
a.resources -= childResources;
|
|
|
|
|
const childTraits = mutateTraits(a.traits, this.rng, 0.035);
|
|
|
|
|
const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources);
|
|
|
|
|
child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1);
|
|
|
|
|
child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1);
|
|
|
|
|
offspring.push(child);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
isDrylandAdapted(ethnicity) {
|
|
|
|
|
return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38;
|
|
|
|
|
}
|
|
|
|
|
ethnicDensityNear(x, y, ethnicity) {
|
|
|
|
|
const key = `${x},${y},${ethnicity}`;
|
|
|
|
|
const cached = this.ethnicDensityCache.get(key);
|
|
|
|
|
if (cached) return cached;
|
|
|
|
|
let same = 0;
|
|
|
|
|
let foreign = 0;
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
|
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
|
|
|
if (Math.abs(dx) + Math.abs(dy) > 2) continue;
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
2026-05-17 21:08:03 +09:00
|
|
|
const counts = this.tileEthnicMix.get(w.idx(tx, ty)) || this.tileEthnicities.get(w.idx(tx, ty));
|
2026-05-13 20:21:58 +09:00
|
|
|
if (!counts) continue;
|
|
|
|
|
for (const [id, value] of counts) {
|
|
|
|
|
if (id === ethnicity) same += value;
|
|
|
|
|
else foreign += value;
|
|
|
|
|
}
|
|
|
|
|
if (same > 6 && foreign > 6) {
|
|
|
|
|
const result = { same: same * 0.22, foreign: foreign * 0.16 };
|
|
|
|
|
this.ethnicDensityCache.set(key, result);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const result = { same: same * 0.22, foreign: foreign * 0.16 };
|
|
|
|
|
this.ethnicDensityCache.set(key, result);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
resolveAssimilation(a) {
|
|
|
|
|
const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity);
|
|
|
|
|
if (!dominant || dominant.id === a.ethnicity) {
|
|
|
|
|
a.foreignContact = Math.max(0, a.foreignContact - 1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (a.contactEthnicity !== dominant.id) {
|
|
|
|
|
a.contactEthnicity = dominant.id;
|
|
|
|
|
a.foreignContact = 0;
|
|
|
|
|
}
|
|
|
|
|
a.foreignContact++;
|
|
|
|
|
const pressure = dominant.count / Math.max(1, dominant.total);
|
2026-05-14 14:38:34 +09:00
|
|
|
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 100);
|
|
|
|
|
if (this.rng.next() < chance * 0.0001) {
|
2026-05-13 20:21:58 +09:00
|
|
|
this.changeAgentEthnicity(a, dominant.id);
|
|
|
|
|
a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
|
|
|
|
|
a.foreignContact = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dominantEthnicityNear(x, y, self) {
|
|
|
|
|
const key = `${x},${y},${self}`;
|
|
|
|
|
const cached = this.dominantEthnicityCache.get(key);
|
|
|
|
|
if (cached) return cached;
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
let total = 0;
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (let dy = -3; dy <= 3; dy++) {
|
|
|
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
|
|
|
if (Math.abs(dx) + Math.abs(dy) > 3) continue;
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
2026-05-17 21:08:03 +09:00
|
|
|
const tileCounts = this.tileEthnicMix.get(w.idx(tx, ty)) || this.tileEthnicities.get(w.idx(tx, ty));
|
2026-05-13 20:21:58 +09:00
|
|
|
if (!tileCounts) continue;
|
|
|
|
|
for (const [id, count] of tileCounts) {
|
|
|
|
|
total += count;
|
|
|
|
|
counts.set(id, (counts.get(id) || 0) + count);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const city of this.getCitiesNear(x, y, 5)) {
|
|
|
|
|
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
|
|
|
|
|
if (!city.ethnicityComposition?.size) continue;
|
|
|
|
|
const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8);
|
|
|
|
|
for (const [id, count] of city.ethnicityComposition) {
|
2026-05-14 14:38:34 +09:00
|
|
|
const weighted = Math.max(1, Math.round(count * influence * 0.015));
|
2026-05-13 20:21:58 +09:00
|
|
|
total += weighted;
|
|
|
|
|
counts.set(id, (counts.get(id) || 0) + weighted);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let best = null;
|
|
|
|
|
for (const [id, count] of counts) {
|
|
|
|
|
if (id !== self && (!best || count > best.count)) best = { id, count, total };
|
|
|
|
|
}
|
|
|
|
|
if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
|
|
|
|
|
if (best) this.dominantEthnicityCache.set(key, best);
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
updateWorldFields(scale = 1) {
|
|
|
|
|
const w = this.world;
|
2026-05-14 00:21:24 +09:00
|
|
|
const pheromoneDecay = Math.pow(SimConfig.route.pheromoneDecay, scale);
|
|
|
|
|
const diffusion = SimConfig.route.pheromoneDiffusion * scale;
|
|
|
|
|
const nextPheromone = diffusion > 0 ? new Float32Array(w.pheromone) : null;
|
2026-05-13 20:21:58 +09:00
|
|
|
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
|
|
|
const decayed = w.pheromone[i] * pheromoneDecay;
|
|
|
|
|
if (nextPheromone) nextPheromone[i] = decayed;
|
|
|
|
|
else w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, decayed);
|
|
|
|
|
}
|
|
|
|
|
if (nextPheromone) {
|
|
|
|
|
for (let y = 0; y < w.size; y++) {
|
|
|
|
|
for (let x = 0; x < w.size; x++) {
|
|
|
|
|
const i = w.idx(x, y);
|
|
|
|
|
if (w.terrain[i] === Terrain.WATER) {
|
|
|
|
|
w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, nextPheromone[i] * 0.85);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let neighborSum = 0;
|
|
|
|
|
let neighborCount = 0;
|
|
|
|
|
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 >= w.size || ty >= w.size) continue;
|
|
|
|
|
const tile = w.idx(tx, ty);
|
|
|
|
|
if (w.terrain[tile] === Terrain.WATER) continue;
|
|
|
|
|
neighborSum += nextPheromone[tile];
|
|
|
|
|
neighborCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const neighborAverage = neighborCount ? neighborSum / neighborCount : nextPheromone[i];
|
|
|
|
|
w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, lerp(nextPheromone[i], neighborAverage, diffusion));
|
|
|
|
|
}
|
|
|
|
|
}
|
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-05-17 21:08:03 +09:00
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
for (let tile = 0; tile < w.count; tile++) {
|
|
|
|
|
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);
|
|
|
|
|
if (this.getCitiesNear(x, y, 5).length) continue;
|
|
|
|
|
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;
|
|
|
|
|
const canFoundCities = this.year >= years(80) && this.year % years(10) === 0;
|
|
|
|
|
const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0;
|
|
|
|
|
const candidateEntries = [...candidates].sort((a, b) => {
|
2026-05-17 21:08:03 +09:00
|
|
|
const scoreA = this.cityFoundingScore(a[0], a[1]);
|
|
|
|
|
const scoreB = this.cityFoundingScore(b[0], b[1]);
|
|
|
|
|
return scoreB - scoreA;
|
2026-05-13 20:21:58 +09:00
|
|
|
});
|
|
|
|
|
for (const [i, group] of candidateEntries) {
|
2026-05-17 21:08:03 +09:00
|
|
|
if (group.count < 8) continue;
|
|
|
|
|
const avgSedentary = clamp(group.sedentary / Math.max(1, group.count), 0, 1);
|
2026-05-13 20:21:58 +09:00
|
|
|
let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null;
|
|
|
|
|
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);
|
|
|
|
|
const foundingChance = clamp((foundingScore - 6) * 0.055, 0.03, 0.72);
|
|
|
|
|
if (avgSedentary < 0.28 || foundingScore < 6 || 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);
|
|
|
|
|
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) {
|
|
|
|
|
const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight);
|
|
|
|
|
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();
|
|
|
|
|
this.rebuildOccupancy();
|
|
|
|
|
this.processCityEconomies();
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
if (c.population <= 0 || c.strength <= 0.06) return false;
|
|
|
|
|
const radius = c.agriculturalRadius;
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
const x = clamp(c.x + dx, 0, w.size - 1);
|
|
|
|
|
const y = clamp(c.y + dy, 0, w.size - 1);
|
|
|
|
|
const d = Math.abs(dx) + Math.abs(dy);
|
|
|
|
|
if (d <= radius) {
|
|
|
|
|
const tile = w.idx(x, y);
|
|
|
|
|
w.city[tile] = c.id;
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
this.rebuildIndexes();
|
2026-05-17 21:08:03 +09:00
|
|
|
this.updatePopulationCapacity();
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), 10).length * 4;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
createCity(x, y, seedGroup = null) {
|
2026-05-17 21:08:03 +09:00
|
|
|
const seedPopulation = seedGroup ? Math.max(14, Math.floor(seedGroup.count * 0.42)) : 10;
|
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-05-17 21:08:03 +09:00
|
|
|
const urbanCount = this.weightedUrbanContribution(count * 0.42, 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,
|
|
|
|
|
storedResources: 34 + (seedGroup?.resources || 0) * 0.55,
|
|
|
|
|
ethnicityComposition: composition,
|
|
|
|
|
pheromoneOutput: 0,
|
|
|
|
|
agriculturalRadius: 2,
|
|
|
|
|
tradeLinks: new Set(),
|
|
|
|
|
activeVisitors: 0,
|
|
|
|
|
age: 0,
|
2026-05-16 13:48:08 +09:00
|
|
|
ageWear: 0,
|
|
|
|
|
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,
|
|
|
|
|
tradeReach: 0
|
|
|
|
|
};
|
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-05-16 13:48:08 +09:00
|
|
|
updateCityAging(city) {
|
|
|
|
|
city.ageWear ??= 0;
|
|
|
|
|
city.peakPopulation = Math.max(city.peakPopulation || 0, city.population || 0);
|
|
|
|
|
const cfg = SimConfig.city;
|
|
|
|
|
const ageStart = cfg.agingStartYears ?? 180;
|
|
|
|
|
if ((city.age || 0) <= ageStart) return;
|
|
|
|
|
city.ageWear = clamp(city.ageWear + (cfg.agingDecayPerYear ?? 0.002), 0, 1);
|
|
|
|
|
}
|
|
|
|
|
cityAgeCapacityFactor(city) {
|
|
|
|
|
return clamp(1 - (city?.ageWear || 0), 0, 1);
|
|
|
|
|
}
|
|
|
|
|
cityAgePopulationLimit(city) {
|
|
|
|
|
const peakPopulation = Math.max(city?.peakPopulation || 0, city?.population || 0, 1);
|
2026-05-17 21:08:03 +09:00
|
|
|
return Math.max(1, peakPopulation * this.cityAgeCapacityFactor(city));
|
2026-05-16 13:48:08 +09:00
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
absorbUrbanPopulation() {
|
|
|
|
|
if (!this.cities.length) return;
|
|
|
|
|
const absorbed = [];
|
|
|
|
|
for (const a of this.agents) {
|
2026-05-14 00:21:24 +09:00
|
|
|
if (!a.alive || a.settled < 3) {
|
2026-05-13 20:21:58 +09:00
|
|
|
absorbed.push(a);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-14 00:21:24 +09:00
|
|
|
const city = this.findCityNear(a.x, a.y, 9);
|
2026-05-14 14:38:34 +09:00
|
|
|
const sedentary = a.traits.sedentary;
|
2026-05-14 00:21:24 +09:00
|
|
|
const nomadism = this.agentNomadism(a);
|
|
|
|
|
const nomadRetention = 1 - nomadism * 0.85;
|
|
|
|
|
const absorbChance = clamp((0.08 + sedentary * 1.05) * clamp(nomadRetention, 0.10, 1), 0.03, 0.92);
|
|
|
|
|
if (!city || this.rng.next() > absorbChance) {
|
2026-05-13 20:21:58 +09:00
|
|
|
absorbed.push(a);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-14 00:21:24 +09:00
|
|
|
const migrants = 1 + Math.floor(Math.min(10, a.resources / 7));
|
|
|
|
|
const urbanWeight = clamp(0.12 + (sedentary - 0.10) * 1.55, 0.12, 1);
|
2026-05-13 20:21:58 +09:00
|
|
|
city.population += migrants;
|
|
|
|
|
city.storedResources += Math.max(0, a.resources) * 0.65;
|
|
|
|
|
const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight);
|
|
|
|
|
if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount);
|
|
|
|
|
city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015;
|
|
|
|
|
city.strength += 0.04;
|
|
|
|
|
}
|
|
|
|
|
this.agents = absorbed;
|
|
|
|
|
}
|
|
|
|
|
weightedUrbanContribution(amount, weight) {
|
|
|
|
|
const value = amount * weight;
|
|
|
|
|
const whole = Math.floor(value);
|
|
|
|
|
return whole + (this.rng.next() < value - whole ? 1 : 0);
|
|
|
|
|
}
|
|
|
|
|
processCityEconomies() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (const city of this.cities) {
|
2026-05-16 13:48:08 +09:00
|
|
|
this.updateCityAging(city);
|
2026-05-13 20:21:58 +09:00
|
|
|
city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
|
|
|
|
|
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;
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
if (Math.abs(dx) + Math.abs(dy) > radius) continue;
|
|
|
|
|
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;
|
|
|
|
|
const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
|
|
|
|
|
const farmingYield = 1 + city.knowledge.farming * 0.85;
|
|
|
|
|
const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
|
|
|
|
|
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull);
|
|
|
|
|
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);
|
|
|
|
|
const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018);
|
|
|
|
|
city.storedResources += tradeIncome;
|
|
|
|
|
const supportRatio = city.activeVisitors / Math.max(1, city.population);
|
|
|
|
|
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-05-16 13:48:08 +09:00
|
|
|
const agePopulationLimit = this.cityAgePopulationLimit(city);
|
|
|
|
|
if (city.storedResources > city.population * 0.16 && city.population > 0 && city.population < agePopulationLimit) {
|
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-05-16 13:48:08 +09:00
|
|
|
const birthRoom = Math.max(0, Math.floor(agePopulationLimit - city.population));
|
|
|
|
|
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))));
|
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-05-16 13:48:08 +09:00
|
|
|
if (city.population > agePopulationLimit) {
|
|
|
|
|
const overLimit = city.population - agePopulationLimit;
|
|
|
|
|
const ageLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.agingOverCapAttrition ?? 0.035))));
|
|
|
|
|
city.population -= ageLoss;
|
|
|
|
|
removeFromComposition(city.ethnicityComposition, ageLoss);
|
|
|
|
|
city.strength -= Math.min(0.08, ageLoss / Math.max(1, city.population + ageLoss) * 0.18);
|
|
|
|
|
}
|
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);
|
|
|
|
|
if (loss > 0 && this.agents.length < this.maxAgents) {
|
|
|
|
|
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) {
|
|
|
|
|
const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1;
|
|
|
|
|
const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
this.agents.push(this.makeAgent(
|
|
|
|
|
clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1),
|
|
|
|
|
clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1),
|
|
|
|
|
dominant,
|
|
|
|
|
mutateTraits(template, this.rng, 0.05),
|
|
|
|
|
this.rng.range(4, 11)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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-05-17 21:08:03 +09:00
|
|
|
// Phase 1: administrative territory derived from city holdings.
|
|
|
|
|
// This does not expand states. Campaign events below change holdings; this
|
|
|
|
|
// pass only projects the visible control field from those holdings.
|
|
|
|
|
clearTerritories() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
w.polity.fill(-1);
|
|
|
|
|
w.control.fill(0);
|
|
|
|
|
w.claim.fill(0);
|
|
|
|
|
w.contested.fill(0);
|
|
|
|
|
}
|
|
|
|
|
recomputeTerritories() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
this.clearTerritories();
|
|
|
|
|
if (!this.polities.length || !this.cities.length) return;
|
|
|
|
|
const best = new Float32Array(w.count);
|
|
|
|
|
const second = new Float32Array(w.count);
|
|
|
|
|
const owner = new Int32Array(w.count);
|
|
|
|
|
owner.fill(-1);
|
|
|
|
|
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) {
|
|
|
|
|
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);
|
|
|
|
|
const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor;
|
|
|
|
|
const radius = clamp(Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45))), 6, maxRange);
|
|
|
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
|
|
|
const manhattan = Math.abs(dx) + Math.abs(dy);
|
|
|
|
|
if (manhattan > radius) continue;
|
|
|
|
|
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;
|
|
|
|
|
const routeBoost = w.tradeRoute[tile] ? 1.28 : 1;
|
|
|
|
|
const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1);
|
|
|
|
|
const effectiveDistance = (manhattan + terrainDrag * 1.4) / routeBoost;
|
|
|
|
|
const distanceFalloff = clamp(1 - effectiveDistance / (radius + 4), 0, 1);
|
|
|
|
|
if (distanceFalloff <= 0) continue;
|
|
|
|
|
const localEthnicity = w.dominantEthnicity[tile];
|
|
|
|
|
const ethnicityFactor = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? 0.82 : 1.06;
|
|
|
|
|
const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 20, 0, 0.28);
|
|
|
|
|
const score = baseInfluence * distanceFalloff * distanceFalloff * routeBoost * ethnicityFactor * (1 + populationAnchor) / (1 + terrainDrag * 0.18);
|
|
|
|
|
if (score <= 0.8) continue;
|
|
|
|
|
if (score > best[tile]) {
|
|
|
|
|
if (owner[tile] !== polity.id) second[tile] = best[tile];
|
|
|
|
|
best[tile] = score;
|
|
|
|
|
owner[tile] = polity.id;
|
|
|
|
|
} else if (owner[tile] !== polity.id && score > second[tile]) {
|
|
|
|
|
second[tile] = score;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const threshold = 2.4;
|
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
|
|
|
if (owner[i] < 0 || best[i] < threshold) continue;
|
|
|
|
|
w.polity[i] = owner[i];
|
|
|
|
|
w.claim[i] = clamp(best[i] / 22, 0, 1);
|
|
|
|
|
w.control[i] = clamp((best[i] - threshold) / 18, 0.12, 1);
|
|
|
|
|
if (second[i] > best[i] * 0.72) w.contested[i] = 1;
|
|
|
|
|
}
|
|
|
|
|
this.applyStoredTerritorialClaims();
|
|
|
|
|
}
|
|
|
|
|
applyStoredTerritorialClaims() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const validPolities = new Set(this.polities.map(polity => polity.id));
|
|
|
|
|
for (const [tile, claim] of [...this.territorialClaims]) {
|
|
|
|
|
if (!validPolities.has(claim.polityId) || w.terrain[tile] === Terrain.WATER) {
|
|
|
|
|
this.territorialClaims.delete(tile);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
w.polity[tile] = claim.polityId;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
territoryWaterAnchor(tile) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const x = tile % w.size;
|
|
|
|
|
const y = Math.floor(tile / w.size);
|
|
|
|
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
|
|
|
for (const [dx, dy] of dirs) {
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
|
|
|
|
const n = w.idx(tx, ty);
|
|
|
|
|
if (w.city[n] >= 0 || w.tradeRoute[n] > 0) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
isBorderTile(tile) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const polityId = w.polity[tile];
|
|
|
|
|
if (polityId < 0) return false;
|
|
|
|
|
const x = tile % w.size;
|
|
|
|
|
const y = Math.floor(tile / w.size);
|
|
|
|
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
|
|
|
for (const [dx, dy] of dirs) {
|
|
|
|
|
const tx = x + dx;
|
|
|
|
|
const ty = y + dy;
|
|
|
|
|
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
|
|
|
|
if (w.polity[w.idx(tx, ty)] !== polityId) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
territoryStatsForPolity(polity) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const stats = { tiles: 0, population: 0, coreTiles: 0, contestedTiles: 0, borderTiles: 0 };
|
|
|
|
|
if (!polity) return stats;
|
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
|
|
|
if (w.polity[i] !== polity.id) continue;
|
|
|
|
|
stats.tiles++;
|
|
|
|
|
stats.population += w.population?.[i] || 0;
|
|
|
|
|
if (w.control[i] >= 0.55 && !w.contested[i]) stats.coreTiles++;
|
|
|
|
|
if (w.contested[i]) stats.contestedTiles++;
|
|
|
|
|
if (this.isBorderTile(i)) stats.borderTiles++;
|
|
|
|
|
}
|
|
|
|
|
if (stats.population <= 0) {
|
|
|
|
|
stats.population = this.getPolityCities(polity).reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
|
|
|
|
|
}
|
|
|
|
|
return stats;
|
|
|
|
|
}
|
|
|
|
|
campaignPressureOnTerritories() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const cfg = SimConfig.campaign || {};
|
|
|
|
|
const radius = cfg.claimRadius ?? 8;
|
|
|
|
|
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);
|
|
|
|
|
const pressure = clamp(0.10 + campaign.progress * 0.28, 0.08, 0.42);
|
|
|
|
|
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) continue;
|
|
|
|
|
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));
|
|
|
|
|
if (w.polity[tile] < 0 || w.polity[tile] === attacker.id) {
|
|
|
|
|
w.polity[tile] = attacker.id;
|
|
|
|
|
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;
|
|
|
|
|
if (city) this.cityById.set(id, city);
|
|
|
|
|
return city;
|
|
|
|
|
}
|
|
|
|
|
getPolityById(id) {
|
|
|
|
|
const cached = this.polityById?.get(id);
|
|
|
|
|
if (cached) return cached;
|
|
|
|
|
const polity = this.polities.find(p => p.id === id) || null;
|
|
|
|
|
if (polity) this.polityById.set(id, polity);
|
|
|
|
|
return polity;
|
|
|
|
|
}
|
|
|
|
|
getPolityCities(polity) {
|
|
|
|
|
const living = [];
|
|
|
|
|
for (const id of [...polity.cityIds]) {
|
|
|
|
|
const city = this.getCityById(id);
|
|
|
|
|
if (!city || city.population <= 0) {
|
|
|
|
|
polity.cityIds.delete(id);
|
|
|
|
|
} else {
|
|
|
|
|
living.push(city);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
cityIds: new Set([centerCity.id]),
|
2026-05-17 21:08:03 +09:00
|
|
|
maxCityCount: 1,
|
|
|
|
|
everHadMultipleCities: false,
|
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,
|
|
|
|
|
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,
|
|
|
|
|
lastCrisisYear: this.year
|
|
|
|
|
};
|
|
|
|
|
centerCity.polityId = id;
|
|
|
|
|
centerCity.loyalty = 1;
|
|
|
|
|
centerCity.receivedAid = false;
|
|
|
|
|
this.polities.push(polity);
|
|
|
|
|
this.polityById.set(polity.id, polity);
|
|
|
|
|
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,
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
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 affectedAgents = 0;
|
|
|
|
|
for (const agent of this.agents) {
|
|
|
|
|
if (!agent.alive) continue;
|
|
|
|
|
const d = Math.hypot(agent.x - x, agent.y - y);
|
|
|
|
|
if (d > radius) continue;
|
|
|
|
|
const falloff = 1 - d / radius;
|
|
|
|
|
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
|
|
|
|
|
if (this.rng.next() < damage * 0.32) {
|
|
|
|
|
if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent);
|
|
|
|
|
agent.alive = false;
|
|
|
|
|
this.deaths++;
|
|
|
|
|
affectedAgents++;
|
|
|
|
|
} else {
|
|
|
|
|
agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45));
|
|
|
|
|
if (agent.tech) {
|
|
|
|
|
agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035);
|
|
|
|
|
agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let affectedCities = 0;
|
|
|
|
|
let totalPopulationLoss = 0;
|
|
|
|
|
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);
|
|
|
|
|
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),
|
|
|
|
|
affectedAgents,
|
|
|
|
|
affectedCities,
|
|
|
|
|
totalPopulationLoss,
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift();
|
|
|
|
|
}
|
|
|
|
|
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-05-17 21:08:03 +09:00
|
|
|
polity.maxCityCount = Math.max(polity.maxCityCount || 0, polity.cityIds.size);
|
|
|
|
|
if ((polity.maxCityCount || 0) > 1) polity.everHadMultipleCities = true;
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
removeCityFromPolity(city) {
|
|
|
|
|
if (!city || city.polityId === null) return;
|
|
|
|
|
const oldPolity = this.getPolityById(city.polityId);
|
|
|
|
|
if (oldPolity) oldPolity.cityIds.delete(city.id);
|
|
|
|
|
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;
|
|
|
|
|
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++;
|
|
|
|
|
const war = {
|
|
|
|
|
id,
|
|
|
|
|
aPolityId: a.id,
|
|
|
|
|
bPolityId: b.id,
|
|
|
|
|
started: this.year,
|
|
|
|
|
lastActionYear: this.year,
|
|
|
|
|
intensity: this.rng.range(0.45, 1.05),
|
|
|
|
|
exhaustionA: 0,
|
|
|
|
|
exhaustionB: 0,
|
|
|
|
|
ended: null
|
|
|
|
|
};
|
|
|
|
|
this.wars.push(war);
|
|
|
|
|
a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5);
|
|
|
|
|
b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5);
|
|
|
|
|
return war;
|
|
|
|
|
}
|
|
|
|
|
endWar(war) {
|
|
|
|
|
if (!war || war.ended !== null) return;
|
|
|
|
|
war.ended = this.year;
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
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-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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expandPolities() {
|
|
|
|
|
for (const polity of this.polities) {
|
|
|
|
|
const center = this.getCityById(polity.centerCityId);
|
|
|
|
|
if (!center) continue;
|
|
|
|
|
const centerInfluence = this.cityInfluence(center);
|
|
|
|
|
let absorbed = 0;
|
|
|
|
|
for (const city of this.cities) {
|
|
|
|
|
if (absorbed >= 2) break;
|
|
|
|
|
if (city.polityId !== null || city.id === center.id) continue;
|
|
|
|
|
const connection = this.polityConnectionToCity(polity, city);
|
|
|
|
|
if (!connection.connected) continue;
|
|
|
|
|
const distance = connection.distance;
|
|
|
|
|
const targetInfluence = this.cityInfluence(city);
|
|
|
|
|
if (centerInfluence <= targetInfluence * 0.9) continue;
|
|
|
|
|
const dominanceScore =
|
|
|
|
|
(centerInfluence / (targetInfluence + 1)) *
|
|
|
|
|
(1 / (1 + distance * 0.08)) *
|
|
|
|
|
(this.hasDirectTradeConnection(connection.source, city) ? 1.5 : 1.0);
|
|
|
|
|
if (dominanceScore > 0.62 && this.rng.next() < 0.24) {
|
|
|
|
|
this.addCityToPolity(city, polity, 0.62);
|
|
|
|
|
absorbed++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
absorbIndependentCities() {
|
|
|
|
|
if (this.year % years(5) !== 0) return;
|
|
|
|
|
for (const polity of this.polities) {
|
|
|
|
|
const cities = this.getPolityCities(polity);
|
|
|
|
|
if (!cities.length) continue;
|
|
|
|
|
const power = this.polityPower(polity);
|
|
|
|
|
const range = this.polityInfluenceRange(polity, false);
|
|
|
|
|
let absorbed = 0;
|
|
|
|
|
const candidates = this.cities
|
|
|
|
|
.filter(city => city.polityId === null && city.population > 0)
|
|
|
|
|
.map(city => ({ city, connection: this.polityConnectionToCity(polity, city) }))
|
|
|
|
|
.filter(x => x.connection.connected && x.connection.distance <= range)
|
|
|
|
|
.sort((a, b) => a.connection.distance - b.connection.distance);
|
|
|
|
|
for (const { city, connection } of candidates) {
|
|
|
|
|
if (absorbed >= 1) break;
|
|
|
|
|
const distance = connection.distance;
|
|
|
|
|
const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1);
|
|
|
|
|
const proximity = 1 / (1 + distance * 0.07);
|
|
|
|
|
const pressure = (power / Math.max(1, cityInfluence)) * proximity;
|
|
|
|
|
if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) {
|
|
|
|
|
this.addCityToPolity(city, polity, 0.42);
|
|
|
|
|
city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99)));
|
|
|
|
|
absorbed++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
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;
|
|
|
|
|
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;
|
|
|
|
|
const value = w.fertility[tile] + clamp(w.resource[tile] / 45, 0, 1) * 0.8 + nearbyTrade + w.mineral[tile] * 0.25 - resistance - connection.distance * 0.018 - w.move[tile] * 0.22;
|
|
|
|
|
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;
|
|
|
|
|
const population = w.population?.[tile] || w.pressure[tile] || 0;
|
|
|
|
|
const ethnicity = w.dominantEthnicity[tile];
|
|
|
|
|
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;
|
|
|
|
|
const score = Math.sqrt(Math.max(1, population)) * 0.38 + strategic + clamp(w.resource[tile] / 45, 0, 1) * 0.35 - connection.distance * 0.016 - w.move[tile] * 0.22 - cultureMismatch + diversity * 0.12;
|
|
|
|
|
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;
|
|
|
|
|
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({
|
|
|
|
|
targetTile: this.world.idx(city.x, city.y),
|
|
|
|
|
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);
|
|
|
|
|
if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48)) continue;
|
|
|
|
|
const border = this.nearestBorderCampaignTile(polity, other);
|
|
|
|
|
const city = this.borderWarTargetCity(polity, other);
|
|
|
|
|
const defenderPower = Math.max(1, this.polityPower(other));
|
|
|
|
|
const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.18 + (city.tradeValue || 0) * 0.5 : 0.25;
|
|
|
|
|
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;
|
|
|
|
|
const score = borderValue - defenderPower / 90 - distance * 0.018 + this.polityPower(polity) / 120 + riskAppetite;
|
|
|
|
|
targets.push({
|
|
|
|
|
targetTile: city ? this.world.idx(city.x, city.y) : border.tile,
|
|
|
|
|
targetCityId: city?.id ?? null,
|
|
|
|
|
defenderPolityId: other.id,
|
|
|
|
|
weight: this.noisyTargetWeight(score)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
|
const maxDistance = SimConfig.campaign?.maxTargetDistance ?? 48;
|
|
|
|
|
let best = null;
|
|
|
|
|
let bestDistance = Infinity;
|
|
|
|
|
for (const city of this.getPolityCities(defender)) {
|
|
|
|
|
const connection = this.polityConnectionToCity(attacker, city);
|
|
|
|
|
if (connection.connected && connection.distance < bestDistance && connection.distance <= maxDistance) {
|
|
|
|
|
best = city;
|
|
|
|
|
bestDistance = connection.distance;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
startCampaign(polity, target) {
|
|
|
|
|
const cfg = SimConfig.campaign || {};
|
|
|
|
|
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,
|
|
|
|
|
attackerPolityId: polity.id,
|
|
|
|
|
defenderPolityId: target.defenderPolityId ?? null,
|
|
|
|
|
targetCityId: target.targetCityId ?? null,
|
|
|
|
|
targetTile,
|
|
|
|
|
targetEthnicity: target.targetEthnicity ?? null,
|
|
|
|
|
started: this.year,
|
|
|
|
|
expires: this.year + duration,
|
|
|
|
|
progress: 0,
|
|
|
|
|
strength: 0,
|
|
|
|
|
resistance: 0,
|
|
|
|
|
status: "active"
|
|
|
|
|
};
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
cityTile(city) {
|
|
|
|
|
return city ? this.world.idx(city.x, city.y) : null;
|
|
|
|
|
}
|
|
|
|
|
updateCampaigns() {
|
|
|
|
|
if (this.year % years(1) !== 0) return;
|
|
|
|
|
for (const campaign of this.campaigns) {
|
|
|
|
|
if (campaign.status !== "active") continue;
|
|
|
|
|
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);
|
|
|
|
|
campaign.progress = clamp(campaign.progress + 0.09 + momentum * 0.18 + this.rng.range(-0.08, 0.08), -0.35, 1.25);
|
|
|
|
|
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);
|
|
|
|
|
const population = targetCity?.population || w.population[tile] || w.pressure[tile] || 0;
|
|
|
|
|
const defenderPower = defender ? this.polityPower(defender) * 0.55 : 0;
|
|
|
|
|
const attackerEthnicity = this.dominantCityEthnicity(connection.source);
|
|
|
|
|
const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : w.dominantEthnicity[tile];
|
|
|
|
|
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,
|
|
|
|
|
(Math.sqrt(Math.max(1, population)) * 3.2 + terrain * 8 + connection.distance * 0.32 + defenderPower) *
|
|
|
|
|
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);
|
|
|
|
|
if (city) {
|
|
|
|
|
const similarity = this.campaignEthnicSimilarity(attacker, campaign);
|
|
|
|
|
if (city.polityId !== null && city.polityId !== attacker.id) this.removeCityFromPolity(city);
|
|
|
|
|
this.addCityToPolity(city, attacker, clamp(0.28 + similarity * 0.34 - campaign.resistance / Math.max(1, campaign.strength + campaign.resistance) * 0.14, 0.16, 0.68));
|
|
|
|
|
}
|
|
|
|
|
this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, "succeeded"), 2);
|
|
|
|
|
} 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) {
|
|
|
|
|
this.removeCityFromPolity(city);
|
|
|
|
|
this.addCityToPolity(city, attacker, 0.22);
|
|
|
|
|
} else {
|
|
|
|
|
this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48);
|
|
|
|
|
}
|
|
|
|
|
if (defender) {
|
|
|
|
|
defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.04, 0, 1);
|
|
|
|
|
defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * 0.04);
|
|
|
|
|
}
|
|
|
|
|
this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2);
|
|
|
|
|
} else if (campaign.type === "nonstate_subjugation") {
|
|
|
|
|
this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.56);
|
|
|
|
|
this.applySubjugationEffects(attacker, campaign);
|
|
|
|
|
this.addPolityEvent(attacker.id, "subjugation", this.year, this.campaignEventData(campaign, "succeeded"), 1);
|
|
|
|
|
} else {
|
|
|
|
|
this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.46);
|
|
|
|
|
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();
|
|
|
|
|
this.recomputeTerritories();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
this.addPolityEvent(attacker.id, "failedCampaign", this.year, this.campaignEventData(campaign, reason), 1);
|
|
|
|
|
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);
|
|
|
|
|
const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.dominantEthnicity[campaign.targetTile];
|
|
|
|
|
if (a === null || b === null || b < 0) return 0.45;
|
|
|
|
|
return a === b ? 1 : 0.25;
|
|
|
|
|
}
|
|
|
|
|
applyCampaignTerritory(polity, centerTile, radius, strength) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const cx = centerTile % w.size;
|
|
|
|
|
const cy = Math.floor(centerTile / w.size);
|
|
|
|
|
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) continue;
|
|
|
|
|
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 / (radius + 1));
|
|
|
|
|
w.polity[tile] = polity.id;
|
|
|
|
|
w.control[tile] = Math.max(w.control[tile], local);
|
|
|
|
|
w.claim[tile] = Math.max(w.claim[tile], local);
|
|
|
|
|
w.contested[tile] = 0;
|
|
|
|
|
this.territorialClaims.set(tile, {
|
|
|
|
|
polityId: polity.id,
|
|
|
|
|
control: Math.max(local, 0.18),
|
|
|
|
|
claim: Math.max(local, 0.22),
|
|
|
|
|
contested: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
applySubjugationEffects(polity, campaign) {
|
|
|
|
|
const localEthnicity = campaign.targetEthnicity ?? this.world.dominantEthnicity[campaign.targetTile];
|
|
|
|
|
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;
|
|
|
|
|
const ethnicity = this.dominantCityEthnicity(source) || this.world.dominantEthnicity[tile];
|
|
|
|
|
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;
|
|
|
|
|
let culture = this.tileEthnicMix.get(tile);
|
|
|
|
|
if (!culture) {
|
|
|
|
|
culture = new Map();
|
|
|
|
|
this.tileEthnicMix.set(tile, culture);
|
|
|
|
|
}
|
|
|
|
|
culture.set(ethnicity, (culture.get(ethnicity) || 0) + migrants);
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
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) {
|
|
|
|
|
const regionalEthnicity = this.world.dominantEthnicity[targetTile];
|
|
|
|
|
const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1);
|
|
|
|
|
composition.set(sourceEthnicity, isFrontier ? 12 : 18);
|
|
|
|
|
}
|
|
|
|
|
const seedPopulation = Math.max(12, Math.round(compositionTotal(composition) * (isFrontier ? 1.2 : 1.6)));
|
|
|
|
|
const city = {
|
|
|
|
|
id: this.nextCity++,
|
|
|
|
|
x: target.x ?? targetTile % w.size,
|
|
|
|
|
y: target.y ?? Math.floor(targetTile / w.size),
|
|
|
|
|
population: seedPopulation,
|
|
|
|
|
storedResources: 18 + (w.resource[targetTile] || 0) * 0.25,
|
|
|
|
|
ethnicityComposition: composition,
|
|
|
|
|
pheromoneOutput: 0,
|
|
|
|
|
agriculturalRadius: 2,
|
|
|
|
|
tradeLinks: new Set(),
|
|
|
|
|
activeVisitors: 0,
|
|
|
|
|
age: 0,
|
|
|
|
|
ageWear: 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,
|
|
|
|
|
tradeReach: 0
|
|
|
|
|
};
|
|
|
|
|
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.addCityToPolity(city, polity, city.loyalty);
|
|
|
|
|
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-05-14 14:38:34 +09:00
|
|
|
if (this.year % years(1) !== 0) return;
|
2026-05-13 20:21:58 +09:00
|
|
|
for (const a of this.polities) {
|
|
|
|
|
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;
|
|
|
|
|
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;
|
|
|
|
|
if (this.rng.next() < clamp(chance, 0, 0.12)) {
|
2026-05-13 20:21:58 +09:00
|
|
|
this.startWar(a, b);
|
|
|
|
|
started = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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() {
|
|
|
|
|
if (this.year % years(2) !== 0) return;
|
|
|
|
|
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 attackerCities = this.getPolityCities(attacker);
|
|
|
|
|
const defenderCities = this.getPolityCities(defender);
|
|
|
|
|
if (!attackerCities.length || !defenderCities.length) return;
|
|
|
|
|
const attackerPower = this.polityPower(attacker);
|
|
|
|
|
const defenderPower = this.polityPower(defender);
|
|
|
|
|
if (attackerPower <= defenderPower * 1.05) return;
|
|
|
|
|
const range = this.polityInfluenceRange(attacker, true);
|
|
|
|
|
const candidates = defenderCities
|
|
|
|
|
.filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2)
|
|
|
|
|
.map(city => ({ city, connection: this.polityConnectionToCity(attacker, city) }))
|
|
|
|
|
.filter(x => x.connection.connected && x.connection.distance <= range)
|
|
|
|
|
.sort((a, b) => a.connection.distance - b.connection.distance);
|
|
|
|
|
if (!candidates.length) return;
|
|
|
|
|
const topCandidates = candidates.slice(0, 3);
|
|
|
|
|
const selected = topCandidates[this.rng.int(topCandidates.length)];
|
|
|
|
|
const pressure = this.warAbsorptionPressure(
|
|
|
|
|
attacker,
|
|
|
|
|
defender,
|
|
|
|
|
selected.city,
|
|
|
|
|
selected.connection.distance,
|
|
|
|
|
attackerPower,
|
|
|
|
|
defenderPower
|
|
|
|
|
);
|
|
|
|
|
const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45);
|
|
|
|
|
if (pressure > 0.95 && this.rng.next() < chance) {
|
|
|
|
|
this.captureCityInWar(selected.city, attacker, defender, war, pressure);
|
|
|
|
|
} else {
|
|
|
|
|
selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) {
|
|
|
|
|
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 *
|
|
|
|
|
(1 + defenderInstability * 0.32) *
|
|
|
|
|
(1 + defenderAge * 0.18) *
|
|
|
|
|
(1 + defenderOverextension * 0.14) *
|
|
|
|
|
(0.65 + loyaltyWeakness * 0.72)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
captureCityInWar(city, attacker, defender, war, pressure) {
|
|
|
|
|
const lossRate = clamp(
|
|
|
|
|
0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025,
|
|
|
|
|
0.05,
|
|
|
|
|
0.22
|
|
|
|
|
);
|
|
|
|
|
const loss = Math.floor((city.population || 0) * lossRate);
|
|
|
|
|
if (loss > 0) {
|
|
|
|
|
city.population = Math.max(1, city.population - loss);
|
|
|
|
|
if (typeof removeFromComposition === "function" && city.ethnicityComposition) {
|
|
|
|
|
removeFromComposition(city.ethnicityComposition, loss);
|
|
|
|
|
}
|
|
|
|
|
this.deaths += Math.floor(loss * 0.35);
|
|
|
|
|
}
|
|
|
|
|
this.removeCityFromPolity(city);
|
|
|
|
|
this.addCityToPolity(city, attacker, 0.28);
|
|
|
|
|
city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36);
|
|
|
|
|
war.lastActionYear = this.year;
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1);
|
|
|
|
|
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
if (age > years(90)) {
|
|
|
|
|
this.endWar(war);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (age > years(10) && noActionFor > years(18)) {
|
|
|
|
|
this.endWar(war);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (exhaustion > 0.85 && this.rng.next() < 0.35) {
|
|
|
|
|
this.endWar(war);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
center.loyalty = 1;
|
|
|
|
|
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;
|
|
|
|
|
city.loyalty = clamp(city.loyalty + delta, 0, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
splitUnloyalCities() {
|
|
|
|
|
for (const polity of this.polities) {
|
|
|
|
|
const instability = this.polityInstability(polity);
|
|
|
|
|
const agePressure = this.polityAgePressure(polity);
|
|
|
|
|
const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28);
|
2026-05-14 20:15:23 +09:00
|
|
|
const rebellions = [];
|
2026-05-13 20:21:58 +09:00
|
|
|
for (const city of this.getPolityCities(polity)) {
|
|
|
|
|
if (city.id === polity.centerCityId || city.loyalty >= threshold) continue;
|
|
|
|
|
const chance =
|
|
|
|
|
(threshold - city.loyalty) * 0.28 +
|
|
|
|
|
instability * 0.012 +
|
|
|
|
|
agePressure * 0.010;
|
2026-05-14 14:38:34 +09:00
|
|
|
if (this.rng.next() < chance) {
|
|
|
|
|
const rebellionLoyalty = city.loyalty ?? 0;
|
|
|
|
|
this.removeCityFromPolity(city);
|
2026-05-14 20:15:23 +09:00
|
|
|
rebellions.push({
|
2026-05-14 14:38:34 +09:00
|
|
|
cityId: city.id,
|
|
|
|
|
population: city.population || 0,
|
|
|
|
|
loyalty: rebellionLoyalty
|
2026-05-14 20:15:23 +09:00
|
|
|
});
|
2026-05-14 14:38:34 +09:00
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
if (rebellions.length === 1) {
|
|
|
|
|
this.addPolityEvent(polity.id, "rebellion", this.year, rebellions[0], 1);
|
|
|
|
|
} else if (rebellions.length > 1) {
|
|
|
|
|
const population = rebellions.reduce((sum, rebellion) => sum + (rebellion.population || 0), 0);
|
|
|
|
|
const avgLoyalty = rebellions.reduce((sum, rebellion) => sum + (rebellion.loyalty || 0), 0) / rebellions.length;
|
|
|
|
|
this.addPolityEvent(polity.id, "rebellion", this.year, {
|
|
|
|
|
cityIds: rebellions.map(rebellion => rebellion.cityId),
|
|
|
|
|
cityCount: rebellions.length,
|
|
|
|
|
population,
|
|
|
|
|
loyalty: avgLoyalty
|
|
|
|
|
}, 3);
|
|
|
|
|
}
|
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) {
|
|
|
|
|
this.removeCityFromPolity(city);
|
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);
|
2026-05-17 21:08:03 +09:00
|
|
|
polity.maxCityCount = Math.max(polity.maxCityCount || 0, cities.length);
|
|
|
|
|
if ((polity.maxCityCount || 0) > 1) polity.everHadMultipleCities = true;
|
2026-05-13 20:21:58 +09:00
|
|
|
if (!cities.length) {
|
|
|
|
|
this.markPolityEnded(polity, "collapsed");
|
|
|
|
|
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;
|
|
|
|
|
center.loyalty = 1;
|
|
|
|
|
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);
|
2026-05-17 21:08:03 +09:00
|
|
|
polity.maxCityCount = Math.max(polity.maxCityCount || 0, cities.length);
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
if (cities.length === 1) {
|
2026-05-17 21:08:03 +09:00
|
|
|
const singleCityFate = this.singleCityPolityCollapseReason(polity, cities[0]);
|
|
|
|
|
if (singleCityFate) {
|
|
|
|
|
this.markPolityEnded(polity, singleCityFate);
|
2026-05-13 20:21:58 +09:00
|
|
|
cities[0].polityId = null;
|
|
|
|
|
cities[0].loyalty = 0.45;
|
|
|
|
|
cities[0].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)) {
|
|
|
|
|
city.polityId = null;
|
|
|
|
|
city.loyalty = 0.45;
|
|
|
|
|
city.receivedAid = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
singleCityPolityCollapseReason(polity, city) {
|
|
|
|
|
if (!polity || !city) return "collapsed";
|
|
|
|
|
const age = this.polityAge(polity);
|
|
|
|
|
if (age < 80) return null;
|
|
|
|
|
const lowPopulation = (city.population || 0) < 18;
|
|
|
|
|
const lowResources = (city.storedResources || 0) < Math.max(1, (city.population || 1) * 0.025);
|
|
|
|
|
const lowInstitutions = (polity.legitimacy ?? 0.7) < 0.18 && (polity.cohesion ?? 0.6) < 0.18;
|
|
|
|
|
const highCrisis = (polity.crisis || 0) > 1.05;
|
|
|
|
|
const cityFailing = city.strength <= 0.12 || city.loyalty < 0.08;
|
|
|
|
|
if ((lowPopulation && (lowResources || cityFailing)) || (lowInstitutions && highCrisis && lowResources)) {
|
|
|
|
|
return polity.everHadMultipleCities ? "fragmented" : "collapsed";
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
updatePolities() {
|
|
|
|
|
if (this.year % years(1) !== 0) return;
|
|
|
|
|
this.cleanupPolities();
|
|
|
|
|
this.foundPolities();
|
|
|
|
|
if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
|
|
|
|
|
this.collectAndRedistributeResources();
|
|
|
|
|
this.detectPolityFamines();
|
|
|
|
|
if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
|
|
|
|
|
if (this.triggerPolityCrises) this.triggerPolityCrises();
|
|
|
|
|
if (this.applyOldStateStress) this.applyOldStateStress();
|
|
|
|
|
if (this.updatePolityLeaders) this.updatePolityLeaders();
|
|
|
|
|
this.updateCityLoyalty();
|
2026-05-17 21:08:03 +09:00
|
|
|
this.maybeStartCampaigns();
|
|
|
|
|
this.updateCampaigns();
|
2026-05-13 20:21:58 +09:00
|
|
|
this.updateWars();
|
|
|
|
|
this.splitUnloyalCities();
|
|
|
|
|
this.cleanupPolities();
|
|
|
|
|
if (this.samplePolityHistories) this.samplePolityHistories();
|
2026-05-17 21:08:03 +09:00
|
|
|
this.syncTilePopulationCulture();
|
|
|
|
|
this.recomputeTerritories();
|
|
|
|
|
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)))];
|
|
|
|
|
const path = this.findTerrainRoute(center.x, center.y, target.x, target.y);
|
|
|
|
|
if (!this.isValidRoutePath(path, target.x, target.y)) continue;
|
2026-05-14 00:21:24 +09:00
|
|
|
if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles || new Set())) continue;
|
2026-05-13 20:21:58 +09:00
|
|
|
const cities = this.getPolityCities(polity);
|
|
|
|
|
const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3;
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
updateTradeRoutes() {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
|
|
|
const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
|
|
|
|
|
if (invalidTerrain) w.tradeRoute[i] = 0;
|
2026-05-14 00:21:24 +09:00
|
|
|
else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) {
|
|
|
|
|
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();
|
|
|
|
|
const pairCandidates = [];
|
|
|
|
|
const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400);
|
|
|
|
|
const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)]));
|
|
|
|
|
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-05-14 00:21:24 +09:00
|
|
|
if (d > SimConfig.route.maxRouteLength - 1) continue;
|
2026-05-13 20:21:58 +09:00
|
|
|
const distanceFactor = clamp((d - 14) / 30, 0, 1);
|
|
|
|
|
const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1);
|
|
|
|
|
const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0;
|
|
|
|
|
const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias;
|
|
|
|
|
pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore);
|
|
|
|
|
const candidates = [];
|
|
|
|
|
for (const pair of pairCandidates.slice(0, maxPairsToRoute)) {
|
|
|
|
|
const { c1, c2, distanceFactor, marketScale } = pair;
|
|
|
|
|
const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
|
|
|
|
|
if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
|
|
|
|
|
const strength = this.routeStrengthForPath(path);
|
2026-05-14 00:21:24 +09:00
|
|
|
const pheromoneSupport = this.routePheromoneSupport(path);
|
|
|
|
|
const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null;
|
|
|
|
|
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
|
2026-05-13 20:21:58 +09:00
|
|
|
if (strength < 0.08) continue;
|
2026-05-14 00:21:24 +09:00
|
|
|
if (ownerPolityId === null && pheromoneSupport < SimConfig.route.routePheromoneBuild) continue;
|
|
|
|
|
if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue;
|
|
|
|
|
const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale;
|
|
|
|
|
candidates.push({ c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId });
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
candidates.sort((a, b) => b.tradeScore - a.tradeScore);
|
|
|
|
|
const maxLinks = Math.max(1, Math.floor(this.cities.length / 2));
|
|
|
|
|
const supportedRoutes = new Set();
|
|
|
|
|
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-05-13 20:21:58 +09:00
|
|
|
const c1Limit = c1.population > 90 ? 3 : 2;
|
|
|
|
|
const c2Limit = c2.population > 90 ? 3 : 2;
|
|
|
|
|
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);
|
|
|
|
|
if (!upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) {
|
|
|
|
|
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);
|
|
|
|
|
this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, 0, ownerPolityId);
|
|
|
|
|
this.exchangeCityResources(c1, c2, strength, path);
|
|
|
|
|
this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid);
|
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)) {
|
|
|
|
|
const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
|
|
|
|
|
w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - (pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 2 : SimConfig.route.routeUnsupportedDecay));
|
|
|
|
|
if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0;
|
|
|
|
|
}
|
2026-05-13 20:21:58 +09:00
|
|
|
}
|
|
|
|
|
this.activeTradeRouteTiles = new Set();
|
|
|
|
|
for (let i = 0; i < w.count; i++) {
|
|
|
|
|
if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i);
|
|
|
|
|
}
|
|
|
|
|
this.diffuseCityKnowledgeThroughTrade();
|
|
|
|
|
}
|
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);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
findTerrainRoute(x1, y1, x2, y2) {
|
|
|
|
|
const w = this.world;
|
|
|
|
|
const path = [];
|
|
|
|
|
const visited = new Set();
|
|
|
|
|
let x = x1;
|
|
|
|
|
let y = y1;
|
2026-05-14 00:21:24 +09:00
|
|
|
const maxSteps = Math.min(SimConfig.route.maxRouteLength, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 24);
|
2026-05-13 20:21:58 +09:00
|
|
|
const directions = [
|
|
|
|
|
[1, 0],
|
|
|
|
|
[-1, 0],
|
|
|
|
|
[0, 1],
|
|
|
|
|
[0, -1]
|
|
|
|
|
];
|
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
|
|
|
const current = w.idx(x, y);
|
|
|
|
|
path.push(current);
|
|
|
|
|
visited.add(current);
|
|
|
|
|
if (x === x2 && y === y2) break;
|
|
|
|
|
let bestX = x;
|
|
|
|
|
let bestY = y;
|
|
|
|
|
let bestScore = Infinity;
|
|
|
|
|
for (const [dx, dy] of directions) {
|
|
|
|
|
const nx = x + dx;
|
|
|
|
|
const ny = y + dy;
|
|
|
|
|
if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
|
|
|
|
|
const i = w.idx(nx, ny);
|
|
|
|
|
const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0);
|
|
|
|
|
const revisitCost = visited.has(i) ? 5 : 0;
|
|
|
|
|
const routeEase = w.tradeRoute[i] ? -2 : 0;
|
|
|
|
|
const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4);
|
|
|
|
|
const distance = Math.abs(nx - x2) + Math.abs(ny - y2);
|
|
|
|
|
const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase;
|
|
|
|
|
if (score < bestScore) {
|
|
|
|
|
bestScore = score;
|
|
|
|
|
bestX = nx;
|
|
|
|
|
bestY = ny;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bestX === x && bestY === y) break;
|
|
|
|
|
x = bestX;
|
|
|
|
|
y = bestY;
|
|
|
|
|
}
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
terrainEase += 1 / Math.max(1, w.move[i]);
|
|
|
|
|
}
|
|
|
|
|
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-05-13 20:21:58 +09:00
|
|
|
isValidRoutePath(path, targetX, targetY) {
|
|
|
|
|
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-05-14 00:21:24 +09:00
|
|
|
if (path.length > SimConfig.route.maxRouteLength) return false;
|
2026-05-13 20:21:58 +09:00
|
|
|
let totalCost = 0;
|
|
|
|
|
let hardTiles = 0;
|
|
|
|
|
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;
|
|
|
|
|
totalCost += w.move[tile];
|
|
|
|
|
if (w.move[tile] > 1.8) hardTiles++;
|
|
|
|
|
}
|
|
|
|
|
return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12;
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
return path.length * SimConfig.route.routeUpkeepPerTile * (1 - pheromoneRelief);
|
|
|
|
|
}
|
|
|
|
|
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-05-13 20:21:58 +09:00
|
|
|
routeConstructionCost(path, polityBacked = false) {
|
|
|
|
|
let weakTiles = 0;
|
|
|
|
|
for (const tile of path) {
|
|
|
|
|
if (this.world.tradeRoute[tile] < 24) weakTiles++;
|
|
|
|
|
}
|
|
|
|
|
return weakTiles * (polityBacked ? 0.22 : 0.16);
|
|
|
|
|
}
|
|
|
|
|
canPayRouteConstructionCost(cityA, cityB, path, polity = null) {
|
|
|
|
|
const cost = this.routeConstructionCost(path, !!polity);
|
|
|
|
|
if (cost <= 0) return true;
|
|
|
|
|
return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost;
|
|
|
|
|
}
|
|
|
|
|
payRouteConstructionCost(cityA, cityB, path, polity = null) {
|
|
|
|
|
const cost = this.routeConstructionCost(path, !!polity);
|
|
|
|
|
if (cost <= 0) return true;
|
|
|
|
|
if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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-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;
|
|
|
|
|
e.activeTraitPopulation = 0;
|
|
|
|
|
e.traitSums = emptyTraitSums();
|
|
|
|
|
}
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
const e = this.ethnicities.get(a.ethnicity);
|
|
|
|
|
if (!e) continue;
|
|
|
|
|
e.population++;
|
|
|
|
|
e.centroidX += a.x;
|
|
|
|
|
e.centroidY += a.y;
|
|
|
|
|
e.activeTraitPopulation++;
|
|
|
|
|
addTraits(e.traitSums, a.traits);
|
|
|
|
|
}
|
|
|
|
|
for (const city of this.cities) {
|
|
|
|
|
for (const [id, count] of city.ethnicityComposition) {
|
|
|
|
|
const e = this.ethnicities.get(id);
|
|
|
|
|
if (!e) continue;
|
|
|
|
|
e.population += count;
|
|
|
|
|
e.centroidX += city.x * count;
|
|
|
|
|
e.centroidY += city.y * count;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const e of this.ethnicities.values()) {
|
|
|
|
|
if (!e.population) continue;
|
|
|
|
|
e.centroidX /= e.population;
|
|
|
|
|
e.centroidY /= e.population;
|
|
|
|
|
const activeTraitCount = Math.max(1, e.activeTraitPopulation);
|
|
|
|
|
e.averageTraits = averageTraits(e.traitSums, activeTraitCount);
|
|
|
|
|
}
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
const e = this.ethnicities.get(a.ethnicity);
|
|
|
|
|
if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits);
|
|
|
|
|
}
|
|
|
|
|
for (const e of this.ethnicities.values()) {
|
|
|
|
|
if (e.population) e.diversity /= e.population;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
splitDivergentEthnicities() {
|
|
|
|
|
for (const e of this.ethnicities.values()) {
|
|
|
|
|
if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue;
|
|
|
|
|
const candidates = [];
|
|
|
|
|
let tempSum = 0;
|
|
|
|
|
let humidSum = 0;
|
|
|
|
|
for (const a of this.agents) {
|
|
|
|
|
if (a.ethnicity !== e.id) continue;
|
|
|
|
|
const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95;
|
|
|
|
|
const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09;
|
|
|
|
|
const tile = this.world.idx(a.x, a.y);
|
|
|
|
|
const climate = this.climateMismatch(e.id, tile) > 0.18;
|
|
|
|
|
if ((far || spatial || climate) && this.rng.next() < 0.72) {
|
|
|
|
|
candidates.push(a);
|
|
|
|
|
tempSum += this.world.temperature[tile];
|
|
|
|
|
humidSum += this.world.humidity[tile];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (candidates.length >= 6) {
|
|
|
|
|
const newId = this.createEthnicity(e.id, {
|
|
|
|
|
temperature: tempSum / candidates.length,
|
|
|
|
|
humidity: humidSum / candidates.length
|
|
|
|
|
});
|
|
|
|
|
for (const a of candidates) a.ethnicity = newId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|