This commit is contained in:
33333-33333 2026-05-12 21:11:53 +09:00
commit 7fa3696d27
2 changed files with 378 additions and 94 deletions

View file

@ -55,6 +55,7 @@
<option value="pheromone">Trade trails</option>
<option value="pressure">Population pressure</option>
<option value="polities">Polities</option>
<option value="technology">Technology</option>
</select>
</label>
</section>
@ -62,13 +63,14 @@
<section class="panel stats" aria-live="polite">
<dl>
<div><dt>Date</dt><dd id="year">0y 1m</dd></div>
<div><dt>Total population</dt><dd id="population">0</dd></div>
<div><dt>Active groups</dt><dd id="activeGroups">0</dd></div>
<div><dt>Urban population</dt><dd id="urbanPopulation">0</dd></div>
<div><dt>Ethnicities</dt><dd id="ethnicities">0</dd></div>
<div><dt>Cities</dt><dd id="cities">0</dd></div>
<div><dt>States</dt><dd id="polities">0</dd></div>
<div><dt>Trade routes</dt><dd id="routes">0</dd></div>
<div><dt>Farming knowledge</dt><dd id="farmingKnowledge">0.000</dd></div>
<div><dt>Metallurgy knowledge</dt><dd id="metallurgyKnowledge">0.000</dd></div>
<div><dt>Collapse deaths</dt><dd id="deaths">0</dd></div>
<div><dt>Frame cost</dt><dd id="frameCost">0ms</dd></div>
</dl>

468
script.js
View file

@ -38,13 +38,14 @@ const els = {
worldSize: document.getElementById("worldSize"),
viewMode: document.getElementById("viewMode"),
year: document.getElementById("year"),
population: document.getElementById("population"),
activeGroups: document.getElementById("activeGroups"),
urbanPopulation: document.getElementById("urbanPopulation"),
ethnicities: document.getElementById("ethnicities"),
cities: document.getElementById("cities"),
polities: document.getElementById("polities"),
routes: document.getElementById("routes"),
farmingKnowledge: document.getElementById("farmingKnowledge"),
metallurgyKnowledge: document.getElementById("metallurgyKnowledge"),
deaths: document.getElementById("deaths"),
frameCost: document.getElementById("frameCost"),
ethnicityList: document.getElementById("ethnicityList"),
@ -388,6 +389,7 @@ class Simulation {
this.deaths = 0;
this.maxAgents = Math.max(initialAgents * 1.25, 30000);
this.tileEthnicities = new Map();
this.tileAgents = new Map();
this.spawnInitialAgents(initialAgents);
this.rebuildOccupancy();
this.updateEthnicStats();
@ -465,7 +467,14 @@ class Simulation {
alive: true,
foreignContact: 0,
contactEthnicity: ethnicity,
settled: 0
settled: 0,
movedThisStep: false,
tech: {
farming: 0,
metallurgy: 0
},
farmingWork: 0,
lastFarmTile: -1
};
}
@ -530,7 +539,14 @@ class Simulation {
for (const a of this.agents) {
if (!a.alive) continue;
this.moveAgent(a);
}
this.rebuildOccupancy();
for (const a of this.agents) {
if (!a.alive) continue;
this.updateAgentTechnology(a);
this.gatherConsumeReproduce(a, offspring);
if (a.alive) this.payTechnologyCostOrForget(a);
this.resolveAssimilation(a);
}
@ -543,13 +559,15 @@ class Simulation {
this.updateWorldFields();
this.rebuildOccupancy();
if (this.year % years(5) === 0) {
if (this.year % years(1) === 0) {
this.updateCities();
}
if (this.year % years(5) === 0) {
this.updateTradeRoutes();
}
this.updatePolities();
this.updateEthnicStats();
if (this.year % years(90) === 0) {
if (this.year % years(45) === 0) {
this.splitDivergentEthnicities();
this.updateEthnicStats();
}
@ -560,10 +578,17 @@ class Simulation {
const w = this.world;
w.pressure.fill(0);
this.tileEthnicities.clear();
this.tileAgents.clear();
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();
@ -587,6 +612,7 @@ class Simulation {
const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
if (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;
@ -638,18 +664,234 @@ class Simulation {
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;
w.pheromone[from] += (w.tradeRoute[from] ? 0.08 : 0.22) * depositScale;
w.pheromone[to] += (w.tradeRoute[to] ? 0.06 : 0.15) * depositScale;
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;
}
farmabilityAt(tile) {
const t = this.world.terrain[tile];
if (t === Terrain.FERTILE) return 1.0;
if (t === Terrain.PLAINS) return 0.75;
if (t === Terrain.FOREST) return 0.55;
if (t === Terrain.DESERT) return 0.25;
return 0.0;
}
updateAgentTechnology(agent, payMaintenance = false) {
if (!agent.alive) return;
this.ensureAgentTech(agent);
this.updateFarmingWork(agent);
this.tryInventTechnology(agent);
this.spreadTechnology(agent);
this.improveTechnologyFromDensity(agent);
if (payMaintenance) this.payTechnologyCostOrForget(agent);
}
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);
}
}
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.000090;
const farmability = this.farmabilityAt(tile);
const sedentary = getSedentary(agent.traits);
const farmingChance =
farmingBase *
(0.25 + farmability) *
(0.4 + sedentary) *
(0.3 + settledFactor);
if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.24);
}
if ((agent.tech?.metallurgy || 0) <= 0.02) {
const metallurgyBase = 0.000055;
const mineral = clamp(w.mineral[tile], 0, 1);
const mineralTerrainBonus =
w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN
? 1.8
: 1.0;
const metallurgyChance =
metallurgyBase *
(0.2 + mineral) *
mineralTerrainBonus *
(0.5 + settledFactor);
if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.20);
}
}
grantTechnologyAround(agent, techName, amount) {
this.ensureAgentTech(agent);
for (const other of this.localAgentsNear(agent.x, agent.y, 2)) {
if (!other.alive) continue;
this.ensureAgentTech(other);
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
if (distance > 2) continue;
const gain = amount * (other === agent ? 1 : 0.55);
other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1);
}
}
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.010 + metallurgy * 0.022;
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.015 + shortage * 0.04, 0.015, 0.12);
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;
let checked = 0;
for (const other of this.localAgentsNear(agent.x, agent.y, 1)) {
if (other === agent || !other.alive) continue;
if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) continue;
if (++checked > 12) break;
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.003;
if (w.city[tile] >= 0) chance += 0.003;
chance = clamp(chance, 0.0008, 0.028);
this.learnTechnologyFrom(agent, other, "farming", chance);
this.learnTechnologyFrom(agent, other, "metallurgy", chance);
}
}
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);
}
}
improveTechnologyFromDensity(agent) {
let farmingCount = 0;
let metallurgyCount = 0;
let farmingSum = 0;
let metallurgySum = 0;
for (const other of this.localAgentsNear(agent.x, agent.y, 2)) {
if (!other.alive) continue;
this.ensureAgentTech(other);
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
if (distance > 2) continue;
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;
}
}
if (farmingCount >= 3) {
const avgFarming = farmingSum / farmingCount;
agent.tech.farming = clamp(agent.tech.farming + 0.0012 * farmingCount * avgFarming, 0, 1);
}
if (metallurgyCount >= 3) {
const avgMetallurgy = metallurgySum / metallurgyCount;
agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.00085 * metallurgyCount * avgMetallurgy, 0, 1);
}
}
localAgentsNear(x, y, radius) {
const w = this.world;
const found = [];
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) found.push(...agents);
}
}
return found;
}
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;
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);
@ -658,7 +900,11 @@ class Simulation {
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 pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18);
const gathered = Math.min(w.resource[i], productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2));
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;
@ -735,8 +981,8 @@ class Simulation {
a.foreignContact++;
const pressure = dominant.count / Math.max(1, dominant.total);
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 120);
if (this.rng.next() < chance * 0.035) {
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30);
if (this.rng.next() < chance * 0.14) {
a.ethnicity = dominant.id;
a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
a.foreignContact = 0;
@ -761,6 +1007,16 @@ class Simulation {
}
}
}
for (const city of this.cities) {
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
if (distance > 5 || !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) {
const weighted = Math.max(1, Math.round(count * influence * 0.08));
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 };
@ -817,7 +1073,7 @@ class Simulation {
const avgSedentary = group.sedentary / group.count;
let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 7);
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 50) {
const foundingChance = clamp((avgSedentary - 0.28) * 1.55, 0, 0.92);
const foundingChance = clamp((avgSedentary - 0.28) * 1.55 * 3, 0, 0.98);
if (avgSedentary < 0.34 || this.rng.next() > foundingChance) continue;
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
this.cities.push(city);
@ -899,18 +1155,18 @@ class Simulation {
if (!this.cities.length) return;
const absorbed = [];
for (const a of this.agents) {
if (!a.alive || a.settled < 9) {
if (!a.alive || a.settled < 5) {
absorbed.push(a);
continue;
}
const city = this.findCityNear(a.x, a.y, 5);
const city = this.findCityNear(a.x, a.y, 7);
const sedentary = getSedentary(a.traits);
if (!city || this.rng.next() > sedentary * sedentary * 0.75) {
if (!city || this.rng.next() > sedentary * 0.85) {
absorbed.push(a);
continue;
}
const migrants = 1 + Math.floor(Math.min(5, a.resources / 10));
const urbanWeight = clamp((sedentary - 0.2) * 1.35, 0.05, 1);
const migrants = 1 + Math.floor(Math.min(8, a.resources / 8));
const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1);
city.population += migrants;
city.storedResources += Math.max(0, a.resources) * 0.65;
const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight);
@ -952,12 +1208,13 @@ class Simulation {
}
city.storedResources += harvested;
const upkeep = city.population * 0.012;
const upkeep = city.population * 0.010;
city.storedResources -= upkeep;
if (city.storedResources > city.population * 0.24 && city.population > 0) {
const births = Math.max(1, Math.floor(city.population * 0.004));
if (city.storedResources > city.population * 0.16 && city.population > 0) {
const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12, 0, 0.8);
const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012)));
city.population += births;
city.storedResources -= births * 0.7;
city.storedResources -= births * 0.55;
addBirthsToComposition(city.ethnicityComposition, births);
}
if (city.storedResources < 0) {
@ -1062,11 +1319,11 @@ class Simulation {
id,
centerCityId: centerCity.id,
cityIds: new Set([centerCity.id]),
treasury: 0,
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.55, 0.9),
cohesion: this.rng.range(0.45, 0.85),
legitimacy: this.rng.range(0.68, 0.94),
cohesion: this.rng.range(0.58, 0.9),
crisis: 0,
lastCrisisYear: this.year
};
@ -1096,10 +1353,6 @@ class Simulation {
return this.polityHistory.get(polity.id);
}
totalPolityPopulation(polity) {
return this.getPolityCities(polity).reduce((sum, city) => sum + city.population, 0);
}
averagePolityLoyalty(polity) {
const cities = this.getPolityCities(polity);
const subordinates = cities.filter(city => city.id !== polity.centerCityId);
@ -1179,7 +1432,7 @@ class Simulation {
const center = this.getCityById(polity.centerCityId);
if (!center || cities.length <= 1) return 0;
const cityCountPressure = Math.max(0, cities.length - 3) * 0.18;
const cityCountPressure = Math.max(0, cities.length - 5) * 0.08;
let distanceSum = 0;
let count = 0;
for (const city of cities) {
@ -1189,7 +1442,7 @@ class Simulation {
}
const avgDistance = count ? distanceSum / count : 0;
const distancePressure = Math.max(0, avgDistance - 18) * 0.018;
const distancePressure = Math.max(0, avgDistance - 28) * 0.008;
return clamp(cityCountPressure + distancePressure, 0, 2);
}
@ -1200,13 +1453,13 @@ class Simulation {
let poor = 0;
for (const city of cities) {
const perCapita = city.storedResources / Math.max(1, city.population);
if (perCapita < 0.08) poor++;
if (perCapita < 0.06) poor++;
}
const povertyRate = poor / cities.length;
const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length);
const treasuryStress = treasuryPerCity < 4 ? (4 - treasuryPerCity) / 4 : 0;
return clamp(povertyRate * 0.75 + treasuryStress * 0.45, 0, 1.5);
const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0;
return clamp(povertyRate * 0.55 + treasuryStress * 0.28, 0, 1.5);
}
polityEthnicFragmentation(polity) {
@ -1232,14 +1485,14 @@ class Simulation {
const resourceStress = this.polityResourceStress(polity);
const fragmentation = this.polityEthnicFragmentation(polity);
const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.65;
const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.45;
const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85;
const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62;
return clamp(
agePressure * 0.55 +
overextension * 0.35 +
resourceStress * 0.55 +
fragmentation * 0.35 +
(polity.crisis || 0) * 0.65 -
agePressure * 0.35 +
overextension * 0.24 +
resourceStress * 0.38 +
fragmentation * 0.22 +
(polity.crisis || 0) * 0.42 -
legitimacyBuffer -
cohesionBuffer,
0,
@ -1249,22 +1502,22 @@ class Simulation {
foundPolities() {
for (const city of this.cities) {
if (city.polityId !== null || city.population < 80 || city.storedResources < 30) continue;
if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue;
const centerInfluence = this.cityInfluence(city);
let absorbed = 0;
for (const other of this.cities) {
if (absorbed >= 2) break;
if (absorbed >= 3) break;
if (other === city || other.polityId !== null) continue;
if (this.distanceBetweenCities(city, other) > 28) continue;
if (this.distanceBetweenCities(city, other) > 34) continue;
const targetInfluence = this.cityInfluence(other);
if (centerInfluence <= targetInfluence * 1.25) continue;
if (centerInfluence <= targetInfluence * 1.08) continue;
const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08);
const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0;
const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus;
if (dominanceScore > 0.9 && this.rng.next() < 0.18) {
if (dominanceScore > 0.68 && this.rng.next() < 0.32) {
const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId);
this.addCityToPolity(other, polity, 0.55);
this.addCityToPolity(other, polity, 0.68);
absorbed++;
}
}
@ -1278,18 +1531,18 @@ class Simulation {
const centerInfluence = this.cityInfluence(center);
let absorbed = 0;
for (const city of this.cities) {
if (absorbed >= 1) break;
if (absorbed >= 2) break;
if (city.polityId !== null || city.id === center.id) continue;
const distance = this.effectiveDistance(center, city);
if (distance > 32) continue;
if (distance > 44) continue;
const targetInfluence = this.cityInfluence(city);
if (centerInfluence <= targetInfluence) continue;
if (centerInfluence <= targetInfluence * 0.9) continue;
const dominanceScore =
(centerInfluence / (targetInfluence + 1)) *
(1 / (1 + distance * 0.08)) *
(this.hasDirectTradeConnection(center, city) ? 1.5 : 1.0);
if (dominanceScore > 0.85 && this.rng.next() < 0.12) {
this.addCityToPolity(city, polity, 0.48);
if (dominanceScore > 0.62 && this.rng.next() < 0.24) {
this.addCityToPolity(city, polity, 0.62);
absorbed++;
}
}
@ -1303,24 +1556,24 @@ class Simulation {
for (const city of cities) city.receivedAid = false;
for (const city of cities) {
if (city.id === centerId) continue;
const tax = city.storedResources * 0.04;
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.45 +
overextension * 2.2 +
agePressure * cities.length * 0.35;
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.025, 0, 1.5);
polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5);
}
const maintenance = Math.pow(cities.length, 1.15) * 0.18;
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);
@ -1330,7 +1583,7 @@ class Simulation {
const paidByCenter = this.drainCityResources(center, unpaid);
if (paidByCenter < unpaid) {
for (const city of cities) {
if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.025, 0, 1);
if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1);
}
}
}
@ -1360,47 +1613,47 @@ class Simulation {
const resourceStress = this.polityResourceStress(polity);
const overextension = this.polityOverextension(polity);
const erosion =
0.004 +
agePressure * 0.006 +
resourceStress * 0.006 +
overextension * 0.003;
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.006, 0, 1);
polity.cohesion = clamp(polity.cohesion + 0.003, 0, 1);
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.92, 0, 1.5);
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 < 60) continue;
if ((this.year - (polity.lastCrisisYear ?? 0)) / MONTHS_PER_YEAR < 96) continue;
if (age < 140) continue;
if ((this.year - (polity.lastCrisisYear ?? 0)) / MONTHS_PER_YEAR < 160) continue;
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const chance = 0.015 + agePressure * 0.035 + instability * 0.025;
const chance = 0.006 + agePressure * 0.018 + instability * 0.014;
if (this.rng.next() >= chance) continue;
polity.lastCrisisYear = this.year;
const severity = clamp(
0.12 +
agePressure * this.rng.range(0.12, 0.28) +
instability * this.rng.range(0.08, 0.22),
0.08,
0.55
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.45, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.25, 0, 1);
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)) {
@ -1448,18 +1701,18 @@ class Simulation {
const perCapita = city.storedResources / Math.max(1, city.population);
const distance = this.effectiveDistance(city, center);
let delta = 0;
delta += clamp((perCapita - 0.12) * 0.08, -0.04, 0.04);
delta += this.sameDominantEthnicity(city, center) ? 0.025 : -0.025;
delta += clamp(0.035 - distance * 0.0015, -0.045, 0.035);
if (this.hasDirectTradeConnection(city, center)) delta += 0.015;
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);
if (this.hasDirectTradeConnection(city, center)) delta += 0.025;
if (city.receivedAid) delta += 0.04;
delta -= 0.01;
if (city.storedResources < city.population * 0.05) delta -= 0.06;
delta -= instability * 0.035;
delta -= agePressure * 0.018;
delta -= overextension * clamp(distance / 40, 0, 1) * 0.025;
if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.035;
if ((polity.crisis || 0) > 0.6) delta -= 0.025;
delta -= 0.004;
if (city.storedResources < city.population * 0.05) delta -= 0.035;
delta -= instability * 0.020;
delta -= agePressure * 0.010;
delta -= overextension * clamp(distance / 48, 0, 1) * 0.014;
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);
}
}
@ -1469,13 +1722,13 @@ class Simulation {
for (const polity of this.polities) {
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const threshold = clamp(0.2 + instability * 0.08 + agePressure * 0.05, 0.2, 0.38);
const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28);
for (const city of this.getPolityCities(polity)) {
if (city.id === polity.centerCityId || city.loyalty >= threshold) continue;
const chance =
(threshold - city.loyalty) * 0.55 +
instability * 0.025 +
agePressure * 0.02;
(threshold - city.loyalty) * 0.28 +
instability * 0.012 +
agePressure * 0.010;
if (this.rng.next() < chance) this.removeCityFromPolity(city);
}
}
@ -1818,23 +2071,23 @@ class Simulation {
splitDivergentEthnicities() {
for (const e of this.ethnicities.values()) {
if (e.activeTraitPopulation < 35 || e.population < 55 || e.diversity < 0.22) continue;
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 * 1.12;
const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.12;
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.26;
if ((far || spatial || climate) && this.rng.next() < 0.58) {
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 >= 10) {
if (candidates.length >= 6) {
const newId = this.createEthnicity(e.id, {
temperature: tempSum / candidates.length,
humidity: humidSum / candidates.length
@ -1928,6 +2181,7 @@ class Simulation {
sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
sim.agents = state.agents || [];
for (const a of sim.agents) sim.ensureAgentTech(a);
sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, {
id: e.id,
parent: e.parent,
@ -2028,6 +2282,8 @@ function render() {
const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
if (polity) color = mix(color, polity.color, 0.5);
}
} else if (mode === "technology") {
color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
} else {
color = terrainInfo[w.terrain[i]].color;
}
@ -2107,6 +2363,15 @@ function drawAgentsAndCities(mode) {
if (!e) continue;
ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
ctx.fillRect(a.x, a.y, 1, 1);
} else if (mode === "technology") {
sim.ensureAgentTech(a);
const farming = a.tech.farming || 0;
const metallurgy = a.tech.metallurgy || 0;
const tech = clamp(Math.max(farming, metallurgy), 0, 1);
if (tech <= 0.01) continue;
const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`;
ctx.fillRect(a.x, a.y, 1, 1);
} else {
ctx.fillStyle = "#eeeccf";
ctx.fillRect(a.x, a.y, 1, 1);
@ -2204,6 +2469,8 @@ function renderTooltip() {
${agent ? `<span><b>Climate mismatch</b><em>${mismatch.toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Sedentary</b><em>${getSedentary(agent.traits).toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Ethnocentrism</b><em>${getEthnocentrism(agent.traits).toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Farming</b><em>${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Metallurgy</b><em>${(agent.tech?.metallurgy || 0).toFixed(3)}</em></span>` : ""}
${agent ? `<span><b>Stored</b><em>${agent.resources.toFixed(1)}</em></span>` : ""}
${ethnicity ? `<span><b>Lineage pop</b><em>${ethnicity.population}</em></span>` : ""}
`;
@ -2250,14 +2517,27 @@ function updateStats(force = false) {
lastStatsAt = now;
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
let farmingTotal = 0;
let metallurgyTotal = 0;
let farmingHolders = 0;
let metallurgyHolders = 0;
for (const a of sim.agents) {
sim.ensureAgentTech(a);
farmingTotal += a.tech.farming || 0;
metallurgyTotal += a.tech.metallurgy || 0;
if ((a.tech.farming || 0) > 0.02) farmingHolders++;
if ((a.tech.metallurgy || 0) > 0.02) metallurgyHolders++;
}
const agentCount = Math.max(1, sim.agents.length);
els.year.textContent = formatSimDate(sim.year);
els.population.textContent = Math.floor(sim.agents.length + urbanPopulation).toLocaleString();
els.activeGroups.textContent = sim.agents.length.toLocaleString();
els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString();
els.ethnicities.textContent = livingEthnicities.length.toLocaleString();
els.cities.textContent = sim.cities.length.toLocaleString();
els.polities.textContent = sim.polities.length.toLocaleString();
els.routes.textContent = sim.tradeLinks.length.toLocaleString();
els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`;
els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`;
els.deaths.textContent = sim.deaths.toLocaleString();
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
@ -2282,6 +2562,8 @@ function setLegend() {
els.legend.innerHTML = "<span><i style=\"background:#e2c679\"></i>City nodes, farmland radius, and stored urban population</span>";
} else if (mode === "polities") {
els.legend.innerHTML = "<span>Color = city-centered state. Uncolored cities are independent.</span>";
} else if (mode === "technology") {
els.legend.innerHTML = "<span><i style=\"background:#5fab5b\"></i>Farming knowledge</span><span><i style=\"background:#caa966\"></i>Metallurgy knowledge</span>";
} else {
els.legend.innerHTML = "<span><i style=\"background:#6bbc55\"></i>Regenerating local resource stock</span>";
}