diff --git a/index.html b/index.html
index 5f90e99..4b44b7f 100644
--- a/index.html
+++ b/index.html
@@ -52,7 +52,6 @@
-
@@ -92,7 +91,7 @@
diff --git a/script.js b/script.js
index 4a14ff6..78b0d28 100644
--- a/script.js
+++ b/script.js
@@ -19,6 +19,47 @@ const terrainInfo = [
];
const MONTHS_PER_YEAR = 12;
+const SAVE_KEY = "civil-emergence-save";
+const SAVE_VERSION = 3;
+const MAX_SAVE_BYTES = 4_500_000;
+
+const SimConfig = Object.freeze({
+ population: Object.freeze({
+ maxAgentsFloor: 8000,
+ maxAgentsScale: 1.15,
+ maxOffspringPerStep: 360,
+ carryingCapacityBase: 2.4,
+ carryingCapacityFertility: 7.8,
+ carryingCapacityMineral: 1.8,
+ carryingCapacityFarmland: 5.5,
+ pressurePenaltyBase: 0.2,
+ pressureReproductionPenalty: 0.16
+ }),
+ render: Object.freeze({
+ graphThrottleMs: 700,
+ statsThrottleMs: 350
+ }),
+ save: Object.freeze({
+ key: SAVE_KEY,
+ version: SAVE_VERSION,
+ maxBytes: MAX_SAVE_BYTES
+ }),
+ technology: Object.freeze({
+ cityDiffusion: 0.0022,
+ tradeDiffusion: 0.0032,
+ cityInnovation: 0.00045
+ }),
+ polity: Object.freeze({
+ minimumPerCapitaFood: 0.06,
+ logisticsDistance: 34
+ }),
+ culture: Object.freeze({
+ spreadRadius: 3,
+ cityWeight: 0.035,
+ routeWeight: 1.35,
+ minimumInfluence: 0.18
+ })
+});
function years(value) {
return value * MONTHS_PER_YEAR;
@@ -62,6 +103,8 @@ let frame = 0;
let lastStatsAt = 0;
let lastGraphRenderAt = 0;
let hoverState = null;
+let renderImage = null;
+let renderImageSize = 0;
class Rng {
constructor(seed) {
@@ -83,7 +126,7 @@ class Rng {
}
class World {
- constructor(size, rng) {
+ constructor(size, rng, generate = true) {
this.size = size;
this.count = size * size;
this.rng = rng;
@@ -101,8 +144,15 @@ class World {
this.cityPull = new Float32Array(this.count);
this.city = new Int32Array(this.count);
this.pressure = new Float32Array(this.count);
+ this.dominantEthnicity = new Int32Array(this.count);
+ this.cultureDiversity = new Float32Array(this.count);
this.city.fill(-1);
- this.generate();
+ this.dominantEthnicity.fill(-1);
+ if (generate) this.generate();
+ }
+
+ static blank(size, rng) {
+ return new World(size, rng, false);
}
idx(x, y) {
@@ -372,9 +422,9 @@ class World {
}
class Simulation {
- constructor(size, initialAgents) {
+ constructor(size, initialAgents, options = {}) {
this.rng = new Rng(Date.now());
- this.world = new World(size, this.rng);
+ this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng);
this.agents = [];
this.ethnicities = new Map();
this.cities = [];
@@ -387,12 +437,17 @@ class Simulation {
this.nextPolity = 1;
this.year = 0;
this.deaths = 0;
- this.maxAgents = Math.max(initialAgents * 1.25, 30000);
+ this.maxAgents = Math.max(
+ Math.floor(initialAgents * SimConfig.population.maxAgentsScale),
+ SimConfig.population.maxAgentsFloor
+ );
this.tileEthnicities = new Map();
this.tileAgents = new Map();
- this.spawnInitialAgents(initialAgents);
- this.rebuildOccupancy();
- this.updateEthnicStats();
+ if (!options.skipSpawn) {
+ this.spawnInitialAgents(initialAgents);
+ this.rebuildOccupancy();
+ this.updateEthnicStats();
+ }
}
spawnInitialAgents(count) {
@@ -551,16 +606,16 @@ class Simulation {
}
this.agents = this.agents.filter(a => a.alive);
- if (this.agents.length + offspring.length < this.maxAgents) {
- this.agents.push(...offspring);
- } else {
- this.agents.push(...offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)));
- }
+ const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents
+ ? offspring
+ : offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length));
+ this.agents.push(...acceptedOffspring);
+ for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
this.updateWorldFields();
- this.rebuildOccupancy();
if (this.year % years(1) === 0) {
this.updateCities();
+ this.updateRegionalCultures();
}
if (this.year % years(5) === 0) {
this.updateTradeRoutes();
@@ -596,6 +651,137 @@ class Simulation {
}
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
}
+ for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile);
+ }
+
+ updateCultureTile(tile) {
+ const counts = this.tileEthnicities.get(tile);
+ 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;
+ }
+
+ 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;
+ const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source]));
+ 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]++;
+ 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);
+ this.updateCultureTile(i);
+ }
+
+ removeAgentFromOccupancy(a) {
+ const w = this.world;
+ const i = w.idx(a.x, a.y);
+ w.pressure[i] = Math.max(0, w.pressure[i] - 1);
+ 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);
+ }
+ this.updateCultureTile(i);
+ }
+
+ 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);
+ }
+ a.ethnicity = nextEthnicity;
+ this.updateCultureTile(i);
}
moveAgent(a) {
@@ -610,7 +796,9 @@ class Simulation {
const ethnocentrism = getEthnocentrism(a.traits);
const ethnicClimate = this.ethnicities.get(a.ethnicity);
const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
- if (localPressure < 7 && this.rng.next() < sedentary * 0.58) {
+ 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;
@@ -631,12 +819,16 @@ class Simulation {
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
- const crowdPenalty = Math.max(0, w.pressure[i] - 3) * (0.35 + a.traits.mobility);
+ 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);
const routeBonus = w.tradeRoute[i] ? 1.25 : 0;
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 = localPressure > 8 ? a.traits.mobility * (1.65 - sedentary * 0.7) : 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 +
@@ -644,6 +836,7 @@ class Simulation {
ethnicDensity.same * ethnocentrism +
cityPull * sedentary +
routePull +
+ capacityPull +
inertia -
terrainPenalty -
crowdPenalty +
@@ -697,12 +890,23 @@ class Simulation {
return 0.0;
}
+ carryingCapacityAt(tile) {
+ const w = this.world;
+ if (w.terrain[tile] === Terrain.WATER) return 1.2;
+ return SimConfig.population.carryingCapacityBase +
+ w.fertility[tile] * SimConfig.population.carryingCapacityFertility +
+ w.mineral[tile] * SimConfig.population.carryingCapacityMineral +
+ w.farmland[tile] * SimConfig.population.carryingCapacityFarmland +
+ (w.city[tile] >= 0 ? 3.5 : 0);
+ }
+
updateAgentTechnology(agent, payMaintenance = false) {
if (!agent.alive) return;
this.ensureAgentTech(agent);
this.updateFarmingWork(agent);
this.tryInventTechnology(agent);
this.spreadTechnology(agent);
+ this.learnTechnologyFromCity(agent);
this.improveTechnologyFromDensity(agent);
if (payMaintenance) this.payTechnologyCostOrForget(agent);
}
@@ -759,14 +963,15 @@ class Simulation {
grantTechnologyAround(agent, techName, amount) {
this.ensureAgentTech(agent);
- for (const other of this.localAgentsNear(agent.x, agent.y, 2)) {
- if (!other.alive) continue;
+ 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) continue;
+ 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) {
@@ -799,10 +1004,10 @@ class Simulation {
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.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 =
@@ -817,7 +1022,8 @@ class Simulation {
this.learnTechnologyFrom(agent, other, "farming", chance);
this.learnTechnologyFrom(agent, other, "metallurgy", chance);
- }
+ return true;
+ });
}
learnTechnologyFrom(agent, other, techName, chance) {
@@ -828,17 +1034,29 @@ class Simulation {
}
}
+ 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);
+ }
+ }
+
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.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) continue;
+ if (distance > 2) return true;
const farming = other.tech.farming || 0;
const metallurgy = other.tech.metallurgy || 0;
if (farming > 0.1) {
@@ -849,7 +1067,8 @@ class Simulation {
metallurgyCount++;
metallurgySum += metallurgy;
}
- }
+ return true;
+ });
if (farmingCount >= 2) {
const avgFarming = farmingSum / farmingCount;
@@ -862,19 +1081,20 @@ class Simulation {
}
}
- localAgentsNear(x, y, radius) {
+ forEachLocalAgentNear(x, y, radius, callback) {
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);
+ if (!agents) continue;
+ for (const agent of agents) {
+ if (callback(agent) === false) return;
+ }
}
}
- return found;
}
farmingGatherMultiplier(agent, tile) {
@@ -903,7 +1123,9 @@ class Simulation {
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 pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18);
+ 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],
@@ -920,6 +1142,7 @@ class Simulation {
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;
@@ -927,8 +1150,9 @@ class Simulation {
const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
const mobilityPenalty = (1 - sedentary) * 0.45;
- const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty - settlementBonus, 0.82, 1.55);
- if (a.resources > reproductionThreshold && offspring.length < 700) {
+ 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;
@@ -990,7 +1214,7 @@ class Simulation {
const pressure = dominant.count / Math.max(1, dominant.total);
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30);
if (this.rng.next() < chance * 0.14) {
- a.ethnicity = dominant.id;
+ this.changeAgentEthnicity(a, dominant.id);
a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
a.foreignContact = 0;
}
@@ -1037,7 +1261,6 @@ class Simulation {
for (let i = 0; i < w.count; i++) {
w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 1.15));
w.pheromone[i] *= 0.996;
- w.pressure[i] *= 0.88;
}
}
@@ -1058,12 +1281,16 @@ class Simulation {
const i = w.idx(cx, cy);
let group = candidates.get(i);
if (!group) {
- group = { count: 0, resources: 0, sedentary: 0, ethnicities: new Map() };
+ group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map() };
candidates.set(i, group);
}
+ this.ensureAgentTech(a);
group.count++;
group.resources += Math.max(0, a.resources);
group.sedentary += getSedentary(a.traits);
+ group.farming += a.tech.farming || 0;
+ group.metallurgy += a.tech.metallurgy || 0;
+ group.techCount++;
group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
}
@@ -1090,6 +1317,9 @@ class Simulation {
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);
@@ -1100,6 +1330,7 @@ class Simulation {
}
this.absorbUrbanPopulation();
+ this.rebuildOccupancy();
this.processCityEconomies();
this.cities = this.cities.filter(c => {
@@ -1157,6 +1388,8 @@ class Simulation {
age: 0,
strength: 3,
sedentaryCulture: seedSedentary,
+ knowledge: { farming: 0, metallurgy: 0 },
+ supplyStress: 0,
polityId: null,
loyalty: 0.5,
receivedAid: false
@@ -1200,6 +1433,7 @@ class Simulation {
for (const city of this.cities) {
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;
@@ -1211,7 +1445,9 @@ class Simulation {
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 extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 + w.mineral[i] * 0.045) * pull);
+ 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);
w.pheromone[i] += city.pheromoneOutput * pull * 0.09;
@@ -1221,8 +1457,12 @@ class Simulation {
city.storedResources += harvested;
const supportRatio = city.activeVisitors / Math.max(1, city.population);
- const upkeep = city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09);
+ const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
+ const upkeep = city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09) + 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, 0, 1.8);
+ this.innovateCityKnowledge(city, harvested);
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)));
@@ -1253,6 +1493,27 @@ class Simulation {
}
}
+ 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();
@@ -1470,15 +1731,37 @@ class Simulation {
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 + treasuryStress * 0.28, 0, 1.5);
+ 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) {
@@ -1502,6 +1785,7 @@ class Simulation {
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;
@@ -1510,6 +1794,7 @@ class Simulation {
agePressure * 0.35 +
overextension * 0.24 +
resourceStress * 0.38 +
+ logisticsStress * 0.30 +
fragmentation * 0.22 +
(polity.crisis || 0) * 0.42 -
legitimacyBuffer -
@@ -1715,6 +2000,7 @@ class Simulation {
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);
@@ -1727,9 +2013,11 @@ class Simulation {
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);
@@ -1896,6 +2184,24 @@ class Simulation {
for (let i = 0; i < w.count; i++) {
if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4);
}
+ this.diffuseCityKnowledgeThroughTrade();
+ }
+
+ 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) {
@@ -2118,7 +2424,7 @@ class Simulation {
toJSON() {
return {
- version: 2,
+ version: SimConfig.save.version,
rngSeed: this.rng.seed,
year: this.year,
deaths: this.deaths,
@@ -2141,7 +2447,9 @@ class Simulation {
farmland: packArray(this.world.farmland),
cityPull: packArray(this.world.cityPull),
city: packArray(this.world.city),
- pressure: packArray(this.world.pressure)
+ pressure: packArray(this.world.pressure),
+ dominantEthnicity: packArray(this.world.dominantEthnicity),
+ cultureDiversity: packArray(this.world.cultureDiversity)
},
agents: this.agents,
ethnicities: [...this.ethnicities.values()].map(e => ({
@@ -2175,7 +2483,8 @@ class Simulation {
}
static fromJSON(state) {
- const sim = new Simulation(state.world.size, 0);
+ if (!state?.world?.size) throw new Error("Invalid save payload");
+ const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true });
sim.rng.seed = state.rngSeed >>> 0;
sim.year = state.year || 0;
sim.deaths = state.deaths || 0;
@@ -2197,7 +2506,9 @@ class Simulation {
sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array));
sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array));
sim.world.city.set(unpackArray(state.world.city, Int32Array));
- sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
+ if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
+ if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array));
+ if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array));
sim.agents = state.agents || [];
for (const a of sim.agents) sim.ensureAgentTech(a);
@@ -2227,6 +2538,11 @@ class Simulation {
age: c.age || 0,
strength: c.strength || 1,
sedentaryCulture: c.sedentaryCulture ?? 0.5,
+ knowledge: {
+ farming: clamp(c.knowledge?.farming ?? 0, 0, 1),
+ metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1)
+ },
+ supplyStress: c.supplyStress ?? 0,
polityId: c.polityId ?? null,
loyalty: c.loyalty ?? 0.5,
receivedAid: c.receivedAid ?? false
@@ -2277,7 +2593,11 @@ function render() {
const start = performance.now();
const w = sim.world;
const size = w.size;
- const image = ctx.createImageData(size, size);
+ if (!renderImage || renderImageSize !== size) {
+ renderImage = ctx.createImageData(size, size);
+ renderImageSize = size;
+ }
+ const image = renderImage;
const data = image.data;
const mode = els.viewMode.value;
@@ -2286,9 +2606,12 @@ function render() {
if (mode === "resources") {
const v = clamp(w.resource[i] / 30, 0, 1);
color = mix([28, 36, 40], [107, 188, 85], v);
- } else if (mode === "pheromone") {
- const v = clamp(w.pheromone[i] / 9, 0, 1);
- color = w.tradeRoute[i] ? mix(terrainInfo[w.terrain[i]].color, [255, 218, 91], 0.38) : mix(terrainInfo[w.terrain[i]].color, [232, 193, 75], v);
+ } else if (mode === "ethnicity") {
+ const id = w.dominantEthnicity[i];
+ const ethnicity = id >= 0 ? sim.ethnicities.get(id) : null;
+ color = ethnicity
+ ? mix(ethnicity.color, [178, 184, 177], clamp(w.cultureDiversity[i] * 0.85, 0, 0.65))
+ : mix(terrainInfo[w.terrain[i]].color, [18, 21, 23], 0.55);
} else if (mode === "pressure") {
const v = clamp(w.pressure[i] / 12, 0, 1);
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
@@ -2460,6 +2783,7 @@ function renderTooltip() {
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
+ const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
els.tooltip.innerHTML = `
${agent ? "Agent group" : terrain.name}
@@ -2472,12 +2796,15 @@ function renderTooltip() {
Minerals${w.mineral[i].toFixed(2)}
Pheromone${w.pheromone[i].toFixed(1)}
Pressure${w.pressure[i].toFixed(0)}
+ Local culture${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}
${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""}
${city ? `City#${city.id}` : ""}
${city ? `Urban pop${city.population.toLocaleString()}` : ""}
${city ? `Food stock${city.storedResources.toFixed(1)}` : ""}
+ ${city ? `Supply stress${(city.supplyStress || 0).toFixed(2)}` : ""}
${city ? `Farmland radius${city.agriculturalRadius}` : ""}
${city ? `Trade links${city.tradeLinks.size}` : ""}
+ ${city ? `City knowledge${(city.knowledge?.farming || 0).toFixed(2)} / ${(city.knowledge?.metallurgy || 0).toFixed(2)}` : ""}
${cityEthnicity ? `City majorityE${cityEthnicity}` : ""}
${city ? `State${polity ? `#${polity.id}` : "Independent"}` : ""}
${city ? `Loyalty${city.loyalty.toFixed(2)}` : ""}
@@ -2537,7 +2864,7 @@ function waterInfluenceAt(world, x, y) {
function updateStats(force = false) {
const now = performance.now();
- if (!force && now - lastStatsAt < 300) return;
+ if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return;
lastStatsAt = now;
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
@@ -2577,9 +2904,7 @@ function setLegend() {
if (mode === "terrain") {
els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join("");
} else if (mode === "ethnicity") {
- els.legend.innerHTML = "Color = inherited ethnicity. New colors appear only by recorded splits or assimilation.";
- } else if (mode === "pheromone") {
- els.legend.innerHTML = "Pheromone trails and permanent trade routes";
+ els.legend.innerHTML = "Color = dominant regional lineage. Mixed tiles brighten toward gray.";
} else if (mode === "pressure") {
els.legend.innerHTML = "High local population pressure";
} else if (mode === "cities") {
@@ -2595,7 +2920,7 @@ function setLegend() {
function maybeRenderStateGraph() {
const now = performance.now();
- if (now - lastGraphRenderAt < 500) return;
+ if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return;
lastGraphRenderAt = now;
renderStateGraph();
}
@@ -2768,6 +3093,8 @@ function reset() {
const count = Number(els.agentCount.value);
els.canvas.width = size;
els.canvas.height = size;
+ renderImage = null;
+ renderImageSize = 0;
sim = new Simulation(size, count);
setLegend();
render();
@@ -2928,23 +3255,49 @@ function unpackArray(payload, TypedArray) {
return new TypedArray(bytes.buffer, 0, payload.length);
}
+const Persistence = Object.freeze({
+ save(currentSim) {
+ const raw = JSON.stringify(currentSim);
+ if (raw.length > SimConfig.save.maxBytes) {
+ throw new Error(`Save is too large (${raw.length.toLocaleString()} bytes)`);
+ }
+ localStorage.setItem(SimConfig.save.key, raw);
+ },
+
+ load() {
+ const raw = localStorage.getItem(SimConfig.save.key);
+ if (!raw) return null;
+ if (raw.length > SimConfig.save.maxBytes * 1.25) {
+ throw new Error("Saved world is too large to load safely");
+ }
+ const state = JSON.parse(raw);
+ if (!state || !state.world || !state.agents) throw new Error("Saved world is missing required fields");
+ return Simulation.fromJSON(state);
+ },
+
+ clear() {
+ localStorage.removeItem(SimConfig.save.key);
+ }
+});
+
function saveWorld() {
try {
- localStorage.setItem("civil-emergence-save", JSON.stringify(sim));
+ Persistence.save(sim);
} catch (error) {
console.warn("Save failed", error);
}
}
function loadWorld() {
- const raw = localStorage.getItem("civil-emergence-save");
- if (!raw) return;
try {
- const state = JSON.parse(raw);
- sim = Simulation.fromJSON(state);
+ const loaded = Persistence.load();
+ if (!loaded) return;
+ sim = loaded;
els.worldSize.value = String(sim.world.size);
els.canvas.width = sim.world.size;
els.canvas.height = sim.world.size;
+ renderImage = null;
+ renderImageSize = 0;
setLegend();
render();
updateStats(true);
@@ -2968,7 +3321,7 @@ els.stepOnce.addEventListener("click", () => {
els.resetWorld.addEventListener("click", reset);
els.saveWorld.addEventListener("click", saveWorld);
els.loadWorld.addEventListener("click", loadWorld);
-els.clearSave.addEventListener("click", () => localStorage.removeItem("civil-emergence-save"));
+els.clearSave.addEventListener("click", () => Persistence.clear());
els.worldSize.addEventListener("change", reset);
els.agentCount.addEventListener("change", reset);
els.viewMode.addEventListener("change", () => {