From 97659fdd2a6e9b7a8e0288517b741f70dedce0a5 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 12 May 2026 23:31:17 +0900 Subject: [PATCH] brush up --- index.html | 3 +- script.js | 477 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 416 insertions(+), 64 deletions(-) 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 @@

State lifespans

- + -
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", () => {