From 8ff52b3fbd46e7e79606ce257c9d1bff9dd93bab Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 12 May 2026 22:19:44 +0900 Subject: [PATCH 1/3] :) --- script.js | 106 ++++++++++++++++++++++++++++++++--------------------- styles.css | 6 ++- 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/script.js b/script.js index cd3d9f2..4a14ff6 100644 --- a/script.js +++ b/script.js @@ -668,8 +668,8 @@ class Simulation { 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; + w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale; + w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale; a.settled = Math.max(0, a.settled - 1); } else { a.movedThisStep = false; @@ -720,6 +720,7 @@ class Simulation { if (farming <= 0.02) return; if (agent.settled > 0) { agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1); + agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1); } } @@ -729,7 +730,7 @@ class Simulation { const settledFactor = clamp((agent.settled || 0) / 12, 0, 1); if ((agent.tech?.farming || 0) <= 0.02) { - const farmingBase = 0.000090; + const farmingBase = 0.00016; const farmability = this.farmabilityAt(tile); const sedentary = getSedentary(agent.traits); const farmingChance = @@ -737,11 +738,11 @@ class Simulation { (0.25 + farmability) * (0.4 + sedentary) * (0.3 + settledFactor); - if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.24); + if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32); } if ((agent.tech?.metallurgy || 0) <= 0.02) { - const metallurgyBase = 0.000055; + const metallurgyBase = 0.00011; const mineral = clamp(w.mineral[tile], 0, 1); const mineralTerrainBonus = w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN @@ -752,7 +753,7 @@ class Simulation { (0.2 + mineral) * mineralTerrainBonus * (0.5 + settledFactor); - if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.20); + if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28); } } @@ -773,7 +774,7 @@ class Simulation { this.ensureAgentTech(agent); const farming = agent.tech.farming || 0; const metallurgy = agent.tech.metallurgy || 0; - const cost = farming * 0.010 + metallurgy * 0.022; + const cost = farming * 0.004 + metallurgy * 0.010; if (cost <= 0) return; if (agent.resources >= cost) { @@ -783,7 +784,7 @@ class Simulation { 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); + const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06); agent.tech.farming = Math.max(0, farming - decay); agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15); if (agent.tech.farming < 0.02) agent.tech.farming = 0; @@ -810,7 +811,7 @@ class Simulation { ethnocentrism * 0.0012; if (sameEthnicity) chance += 0.004; - if (w.tradeRoute[tile]) chance += 0.003; + if (w.tradeRoute[tile]) chance += 0.006; if (w.city[tile] >= 0) chance += 0.003; chance = clamp(chance, 0.0008, 0.028); @@ -850,14 +851,14 @@ class Simulation { } } - if (farmingCount >= 3) { + if (farmingCount >= 2) { const avgFarming = farmingSum / farmingCount; - agent.tech.farming = clamp(agent.tech.farming + 0.0012 * farmingCount * avgFarming, 0, 1); + agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1); } - if (metallurgyCount >= 3) { + if (metallurgyCount >= 2) { const avgMetallurgy = metallurgySum / metallurgyCount; - agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.00085 * metallurgyCount * avgMetallurgy, 0, 1); + agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1); } } @@ -885,6 +886,9 @@ class Simulation { metallurgyGatherMultiplier(agent, tile) { const metallurgyLevel = agent.tech?.metallurgy || 0; + if (metallurgyLevel > 0.02) { + agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1); + } return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1); } @@ -929,7 +933,10 @@ class Simulation { const childResources = a.resources * childShare; a.resources -= childResources; const childTraits = mutateTraits(a.traits, this.rng, 0.035); - offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources)); + const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources); + child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1); + child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1); + offspring.push(child); } } @@ -1029,7 +1036,7 @@ class Simulation { const w = this.world; 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.992; + w.pheromone[i] *= 0.996; w.pressure[i] *= 0.88; } } @@ -1061,20 +1068,20 @@ class Simulation { } let foundedThisTick = 0; - const canFoundCities = this.year >= years(180) && this.year % years(30) === 0; - const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(1200)) : 0; + const canFoundCities = this.year >= years(80) && this.year % years(10) === 0; + const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0; const candidateEntries = [...candidates].sort((a, b) => { const sedentaryA = a[1].sedentary / Math.max(1, a[1].count); const sedentaryB = b[1].sedentary / Math.max(1, b[1].count); return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02); }); for (const [i, group] of candidateEntries) { - if (group.count < 6) continue; + if (group.count < 3) continue; 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 * 3, 0, 0.98); - if (avgSedentary < 0.34 || this.rng.next() > foundingChance) continue; + if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 80) { + const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98); + if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); this.cities.push(city); foundedThisTick++; @@ -1097,9 +1104,14 @@ class Simulation { this.cities = this.cities.filter(c => { c.age++; - c.strength *= 0.996; - if (c.population < 10) c.strength -= 0.02; - if (c.population <= 0 || c.strength <= 0.12) return false; + c.strength *= 0.992; + const foodPerCapita = c.storedResources / Math.max(1, c.population); + if (c.age > 20) { + if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035; + else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015; + if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018; + } + if (c.population <= 0 || c.strength <= 0.06) return false; const radius = c.agriculturalRadius; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { @@ -1121,7 +1133,7 @@ class Simulation { } createCity(x, y, seedGroup = null) { - const seedPopulation = seedGroup ? Math.max(8, seedGroup.count * 3) : 8; + const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10; const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5; const composition = new Map(); if (seedGroup) { @@ -1136,14 +1148,14 @@ class Simulation { x, y, population: seedPopulation, - storedResources: 24 + (seedGroup?.resources || 0) * 0.4, + storedResources: 34 + (seedGroup?.resources || 0) * 0.55, ethnicityComposition: composition, pheromoneOutput: 0, agriculturalRadius: 2, tradeLinks: new Set(), activeVisitors: 0, age: 0, - strength: 2, + strength: 3, sedentaryCulture: seedSedentary, polityId: null, loyalty: 0.5, @@ -1202,13 +1214,14 @@ class Simulation { const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 + w.mineral[i] * 0.045) * pull); w.resource[i] -= extraction; w.farmland[i] = Math.max(w.farmland[i], pull); - w.pheromone[i] += city.pheromoneOutput * pull * 0.04; + w.pheromone[i] += city.pheromoneOutput * pull * 0.09; harvested += extraction; } } city.storedResources += harvested; - const upkeep = city.population * 0.010; + const supportRatio = city.activeVisitors / Math.max(1, city.population); + const upkeep = city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09); city.storedResources -= upkeep; 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); @@ -1217,6 +1230,12 @@ class Simulation { city.storedResources -= births * 0.55; addBirthsToComposition(city.ethnicityComposition, births); } + if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) { + const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010))); + city.population -= attrition; + removeFromComposition(city.ethnicityComposition, attrition); + city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012; + } if (city.storedResources < 0) { const deficit = Math.abs(city.storedResources); const dominant = dominantComposition(city.ethnicityComposition); @@ -1797,7 +1816,7 @@ class Simulation { reinforcePolityTradeRoutes() { const w = this.world; for (const polity of this.polities) { - if (this.rng.next() > 0.32) continue; + if (this.rng.next() > 0.48) continue; const center = this.getCityById(polity.centerCityId); if (!center) continue; const candidates = this.getPolityCities(polity) @@ -1844,24 +1863,24 @@ class Simulation { const c1 = this.cities[a]; const c2 = this.cities[b]; const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); - if (d > 34) continue; + if (d > 44) continue; const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y); if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; const strength = this.routeStrengthForPath(path); - if (strength < 0.12) continue; + if (strength < 0.08) continue; if (!this.canPayRouteConstructionCost(c1, c2, path)) continue; candidates.push({ c1, c2, strength, path }); } } candidates.sort((a, b) => b.strength - a.strength); - const maxLinks = Math.max(1, Math.floor(this.cities.length / 3)); + const maxLinks = Math.max(1, Math.floor(this.cities.length / 2)); const supportedRoutes = new Set(); for (const candidate of candidates) { if (this.tradeLinks.length >= maxLinks) break; const { c1, c2, strength, path } = candidate; - const c1Limit = c1.population > 90 ? 2 : 1; - const c2Limit = c2.population > 90 ? 2 : 1; + const c1Limit = c1.population > 90 ? 3 : 2; + const c2Limit = c2.population > 90 ? 3 : 2; if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; if (!this.payRouteConstructionCost(c1, c2, path)) continue; c1.tradeLinks.add(c2.id); @@ -1870,12 +1889,12 @@ class Simulation { this.exchangeCityResources(c1, c2, strength, path); for (const tile of path) { supportedRoutes.add(tile); - w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 12); + w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18); } } for (let i = 0; i < w.count; i++) { - if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 10); + if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4); } } @@ -2475,10 +2494,15 @@ function renderTooltip() { ${ethnicity ? `Lineage pop${ethnicity.population}` : ""} `; els.tooltip.hidden = false; - const left = Math.min(hoverState.left, hoverState.width - 280); - const top = Math.min(hoverState.top, hoverState.height - 230); - els.tooltip.style.left = `${Math.max(8, left)}px`; - els.tooltip.style.top = `${Math.max(8, top)}px`; + const margin = 8; + const width = els.tooltip.offsetWidth; + const height = els.tooltip.offsetHeight; + let left = hoverState.left; + let top = hoverState.top; + if (left + width + margin > hoverState.width) left = hoverState.left - width - 32; + if (top + height + margin > hoverState.height) top = hoverState.height - height - margin; + els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`; + els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; } function hideTooltip() { diff --git a/styles.css b/styles.css index 48ae1e8..72e281f 100644 --- a/styles.css +++ b/styles.css @@ -284,7 +284,9 @@ canvas#world { position: absolute; z-index: 3; min-width: 180px; - max-width: 260px; + max-width: min(300px, calc(100% - 16px)); + max-height: calc(100% - 16px); + overflow: auto; padding: 8px 10px; border: 1px solid #435057; border-radius: 6px; @@ -292,7 +294,7 @@ canvas#world { color: var(--text); font-size: 11px; line-height: 1.35; - pointer-events: none; + pointer-events: auto; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); } From 97659fdd2a6e9b7a8e0288517b741f70dedce0a5 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 12 May 2026 23:31:17 +0900 Subject: [PATCH 2/3] 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", () => { From 597b2768256bb38b5c0ec926758c6e896959b486 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 13 May 2026 13:19:12 +0900 Subject: [PATCH 3/3] tweak --- index.html | 1 + script.js | 487 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 450 insertions(+), 38 deletions(-) diff --git a/index.html b/index.html index 4b44b7f..96451c4 100644 --- a/index.html +++ b/index.html @@ -67,6 +67,7 @@
Ethnicities
0
Cities
0
States
0
+
Wars
0
Trade routes
0
Farming knowledge
0.000
Metallurgy knowledge
0.000
diff --git a/script.js b/script.js index 78b0d28..08daf48 100644 --- a/script.js +++ b/script.js @@ -35,9 +35,14 @@ const SimConfig = Object.freeze({ pressurePenaltyBase: 0.2, pressureReproductionPenalty: 0.16 }), + city: Object.freeze({ + maxCities: 300 + }), render: Object.freeze({ graphThrottleMs: 700, - statsThrottleMs: 350 + statsThrottleMs: 350, + historyWindowYears: 2000, + historySamplePaddingYears: 240 }), save: Object.freeze({ key: SAVE_KEY, @@ -84,6 +89,7 @@ const els = { ethnicities: document.getElementById("ethnicities"), cities: document.getElementById("cities"), polities: document.getElementById("polities"), + wars: document.getElementById("wars"), routes: document.getElementById("routes"), farmingKnowledge: document.getElementById("farmingKnowledge"), metallurgyKnowledge: document.getElementById("metallurgyKnowledge"), @@ -429,12 +435,14 @@ class Simulation { this.ethnicities = new Map(); this.cities = []; this.polities = []; + this.wars = []; this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; this.nextEthnicity = 1; this.nextCity = 1; this.nextPolity = 1; + this.nextWar = 1; this.year = 0; this.deaths = 0; this.maxAgents = Math.max( @@ -1306,7 +1314,7 @@ class Simulation { if (group.count < 3) continue; 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 < 80) { + if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98); if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); @@ -1652,7 +1660,8 @@ class Simulation { treasury: polity.treasury || 0, avgLoyalty: this.averagePolityLoyalty(polity) }); - while (history.samples.length > 160) history.samples.shift(); + const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; + while (history.samples.length > maxSamples) history.samples.shift(); } samplePolityHistories() { @@ -1696,6 +1705,128 @@ class Simulation { city.receivedAid = false; } + isPolityAtWar(polityId) { + return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)); + } + + getWarBetween(aId, bId) { + return this.wars.find(war => + war.ended === null && + ((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId)) + ) || null; + } + + startWar(a, b) { + if (!a || !b || a.id === b.id) return null; + if (this.getWarBetween(a.id, b.id)) return null; + if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null; + + const id = this.nextWar++; + const war = { + id, + aPolityId: a.id, + bPolityId: b.id, + started: this.year, + lastActionYear: this.year, + intensity: this.rng.range(0.45, 1.05), + exhaustionA: 0, + exhaustionB: 0, + ended: null + }; + this.wars.push(war); + a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); + b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); + return war; + } + + endWar(war) { + if (war && war.ended === null) war.ended = this.year; + } + + totalPolityPopulation(polity) { + return this.getPolityCities(polity) + .reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); + } + + averagePolityTechnology(polity) { + const cities = this.getPolityCities(polity); + if (!cities.length) return 0; + let total = 0; + let count = 0; + for (const city of cities) { + if (!city?.knowledge) continue; + total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5; + count++; + } + return count ? total / count : 0; + } + + polityPower(polity) { + const cities = this.getPolityCities(polity); + if (!cities.length) return 0; + + const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0); + const treasury = Math.max(0, polity.treasury || 0); + const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5; + const tech = this.averagePolityTechnology(polity); + const instability = this.polityInstability ? this.polityInstability(polity) : 0; + const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0; + + return Math.max(0, + Math.sqrt(population) * 1.35 + + Math.sqrt(treasury) * 1.15 + + avgLoyalty * 16 + + tech * 24 - + instability * 12 - + agePressure * 6 + ); + } + + polityInfluenceRange(polity, wartime = false) { + const power = this.polityPower(polity); + const base = 18 + Math.sqrt(power) * 2.2; + const range = wartime ? base * 1.15 : base * 0.75; + return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42); + } + + distanceToNearestPolityCity(polity, city) { + if (!polity || !city) return Infinity; + let best = Infinity; + for (const source of this.getPolityCities(polity)) { + const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); + if (distance < best) best = distance; + } + return best; + } + + nearestPolityCity(polity, city) { + if (!polity || !city) return null; + let best = null; + let bestDistance = Infinity; + for (const source of this.getPolityCities(polity)) { + const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); + if (distance < bestDistance) { + best = source; + bestDistance = distance; + } + } + return best; + } + + polityDistance(a, b) { + const aCities = this.getPolityCities(a); + const bCities = this.getPolityCities(b); + if (!aCities.length || !bCities.length) return Infinity; + let best = Infinity; + for (const aCity of aCities) { + for (const bCity of bCities) { + const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity); + if (distance < best) best = distance; + } + } + return best; + } + polityAge(polity) { return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR; } @@ -1853,6 +1984,288 @@ class Simulation { } } + absorbIndependentCities() { + if (this.year % years(5) !== 0) return; + + for (const polity of this.polities) { + const cities = this.getPolityCities(polity); + if (!cities.length) continue; + + const power = this.polityPower(polity); + const range = this.polityInfluenceRange(polity, false); + let absorbed = 0; + + const candidates = this.cities + .filter(city => city.polityId === null && city.population > 0) + .map(city => ({ + city, + distance: this.distanceToNearestPolityCity(polity, city) + })) + .filter(x => x.distance <= range) + .sort((a, b) => a.distance - b.distance); + + for (const { city, distance } of candidates) { + if (absorbed >= 1) break; + + const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1); + const proximity = 1 / (1 + distance * 0.07); + const pressure = (power / Math.max(1, cityInfluence)) * proximity; + + if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) { + this.addCityToPolity(city, polity, 0.42); + city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99))); + absorbed++; + } + } + } + } + + maybeStartWars() { + if (this.year % years(10) !== 0) return; + + for (const a of this.polities) { + if (this.isPolityAtWar(a.id)) continue; + + const aPower = this.polityPower(a); + if (aPower <= 0) continue; + + let started = false; + + for (const b of this.polities) { + if (started) break; + if (a.id >= b.id) continue; + if (this.isPolityAtWar(b.id)) continue; + if (this.getWarBetween(a.id, b.id)) continue; + + const distance = this.polityDistance(a, b); + if (distance > 46) continue; + + const bPower = this.polityPower(b); + if (bPower <= 0) continue; + + const larger = Math.max(aPower, bPower); + const smaller = Math.min(aPower, bPower); + const advantage = larger / Math.max(1, smaller); + + const proximity = clamp((46 - distance) / 46, 0, 1); + const aInstability = this.polityInstability ? this.polityInstability(a) : 0; + const bInstability = this.polityInstability ? this.polityInstability(b) : 0; + const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0; + const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0; + const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0; + const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; + + const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1); + const weakSideInstability = aPower > bPower ? bInstability : aInstability; + const weakSideAge = aPower > bPower ? bAge : aAge; + const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension; + const generalInstability = Math.max(aInstability, bInstability) * 0.04; + const borderFriction = this.borderFriction(a, b, distance); + + const chance = + 0.002 + + proximity * 0.010 + + asymmetryPressure * 0.010 + + weakSideInstability * 0.020 + + weakSideAge * 0.012 + + weakSideOverextension * 0.012 + + borderFriction * 0.010 + + generalInstability; + + if (this.rng.next() < clamp(chance, 0, 0.08)) { + this.startWar(a, b); + started = true; + } + } + } + } + + borderFriction(a, b, distance) { + if (!Number.isFinite(distance)) return 0; + const proximity = clamp((34 - distance) / 34, 0, 1); + const aCities = this.getPolityCities(a).length; + const bCities = this.getPolityCities(b).length; + const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1); + return proximity * (0.35 + sizePressure * 0.65); + } + + updateWars() { + if (this.year % years(2) !== 0) return; + + for (const war of this.wars) { + if (war.ended !== null) continue; + + const a = this.getPolityById(war.aPolityId); + const b = this.getPolityById(war.bPolityId); + + if (!a || !b) { + war.ended = this.year; + continue; + } + + this.applyWarPressure(war, a, b); + this.applyWarPressure(war, b, a); + this.applyWarExhaustion(war, a, b); + this.maybeEndWar(war); + } + + this.wars = this.wars.filter(w => w.ended === null); + } + + applyWarPressure(war, attacker, defender) { + const attackerCities = this.getPolityCities(attacker); + const defenderCities = this.getPolityCities(defender); + if (!attackerCities.length || !defenderCities.length) return; + + const attackerPower = this.polityPower(attacker); + const defenderPower = this.polityPower(defender); + if (attackerPower <= defenderPower * 1.05) return; + + const range = this.polityInfluenceRange(attacker, true); + + const candidates = defenderCities + .filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2) + .map(city => ({ + city, + distance: this.distanceToNearestPolityCity(attacker, city) + })) + .filter(x => x.distance <= range) + .sort((a, b) => a.distance - b.distance); + + if (!candidates.length) return; + + const topCandidates = candidates.slice(0, 3); + const selected = topCandidates[this.rng.int(topCandidates.length)]; + const pressure = this.warAbsorptionPressure( + attacker, + defender, + selected.city, + selected.distance, + attackerPower, + defenderPower + ); + + const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45); + + if (pressure > 0.95 && this.rng.next() < chance) { + this.captureCityInWar(selected.city, attacker, defender, war, pressure); + } else { + selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1); + } + } + + warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) { + const powerRatio = attackerPower / Math.max(1, defenderPower); + const proximity = 1 / (1 + distance * 0.055); + const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; + const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0; + const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0; + const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1); + + const techAdvantage = 1 + clamp( + this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender), + -0.25, + 0.45 + ); + + const nearest = this.nearestPolityCity(attacker, city); + const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92; + const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0; + + return ( + powerRatio * + proximity * + techAdvantage * + ethnicityFactor * + tradeFactor * + (1 + defenderInstability * 0.32) * + (1 + defenderAge * 0.18) * + (1 + defenderOverextension * 0.14) * + (0.65 + loyaltyWeakness * 0.72) + ); + } + + captureCityInWar(city, attacker, defender, war, pressure) { + const lossRate = clamp( + 0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025, + 0.05, + 0.22 + ); + + const loss = Math.floor((city.population || 0) * lossRate); + if (loss > 0) { + city.population = Math.max(1, city.population - loss); + if (typeof removeFromComposition === "function" && city.ethnicityComposition) { + removeFromComposition(city.ethnicityComposition, loss); + } + this.deaths += Math.floor(loss * 0.35); + } + + this.removeCityFromPolity(city); + this.addCityToPolity(city, attacker, 0.28); + + city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); + + war.lastActionYear = this.year; + + if (war.aPolityId === attacker.id) { + war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1); + war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); + } else { + war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1); + war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); + } + + attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015); + attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5); + + defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5); + defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1); + } + + applyWarExhaustion(war, a, b) { + const intensity = war.intensity || 0.75; + + const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035; + const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035; + + a.treasury = Math.max(0, (a.treasury || 0) - costA); + b.treasury = Math.max(0, (b.treasury || 0) - costB); + + a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5); + b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5); + + war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1); + war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1); + } + + maybeEndWar(war) { + const a = this.getPolityById(war.aPolityId); + const b = this.getPolityById(war.bPolityId); + if (!a || !b) { + war.ended = this.year; + return; + } + + const age = this.year - war.started; + const noActionFor = this.year - war.lastActionYear; + const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0); + + if (age > years(90)) { + war.ended = this.year; + return; + } + + if (age > years(10) && noActionFor > years(18)) { + war.ended = this.year; + return; + } + + if (exhaustion > 0.85 && this.rng.next() < 0.35) { + war.ended = this.year; + } + } + collectAndRedistributeResources() { for (const polity of this.polities) { const cities = this.getPolityCities(polity); @@ -2090,13 +2503,21 @@ class Simulation { this.cleanupPolities(); this.foundPolities(); this.expandPolities(); + if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); + this.collectAndRedistributeResources(); - this.erodePolityLegitimacy(); - this.triggerPolityCrises(); - this.applyOldStateStress(); + + if (this.erodePolityLegitimacy) this.erodePolityLegitimacy(); + if (this.triggerPolityCrises) this.triggerPolityCrises(); + if (this.applyOldStateStress) this.applyOldStateStress(); + this.updateCityLoyalty(); + this.absorbIndependentCities(); + this.maybeStartWars(); + this.updateWars(); this.splitUnloyalCities(); + this.cleanupPolities(); if (this.samplePolityHistories) this.samplePolityHistories(); } @@ -2431,6 +2852,7 @@ class Simulation { nextEthnicity: this.nextEthnicity, nextCity: this.nextCity, nextPolity: this.nextPolity, + nextWar: this.nextWar, maxAgents: this.maxAgents, world: { size: this.world.size, @@ -2477,6 +2899,7 @@ class Simulation { crisis: p.crisis, lastCrisisYear: p.lastCrisisYear })), + wars: this.wars, polityHistory: [...this.polityHistory.values()], deadPolityHistories: this.deadPolityHistories }; @@ -2491,6 +2914,7 @@ class Simulation { sim.nextEthnicity = state.nextEthnicity || 1; sim.nextCity = state.nextCity || 1; sim.nextPolity = state.nextPolity || 1; + sim.nextWar = state.nextWar || 1; sim.maxAgents = state.maxAgents || 30000; sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); @@ -2559,6 +2983,19 @@ class Simulation { crisis: p.crisis ?? 0, lastCrisisYear: p.lastCrisisYear ?? sim.year })); + sim.wars = (state.wars || []).map(w => ({ + id: w.id, + aPolityId: w.aPolityId, + bPolityId: w.bPolityId, + started: w.started || sim.year, + lastActionYear: w.lastActionYear || w.started || sim.year, + intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25), + exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1), + exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1), + ended: w.ended ?? null + })).filter(w => w.ended === null); + const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0); + sim.nextWar = Math.max(sim.nextWar, maxWarId + 1); sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, { id: h.id, color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), @@ -2615,8 +3052,6 @@ function render() { } else if (mode === "pressure") { const v = clamp(w.pressure[i] / 12, 0, 1); color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v); - } else if (mode === "cities") { - color = w.city[i] >= 0 ? mix([74, 105, 58], [226, 198, 121], clamp(w.farmland[i], 0, 1)) : terrainInfo[w.terrain[i]].color; } else if (mode === "polities") { color = terrainInfo[w.terrain[i]].color; if (w.city[i] >= 0) { @@ -2638,7 +3073,6 @@ function render() { ctx.putImageData(image, 0, 0); drawTradeLinks(); - drawFarmlandRings(mode); drawAgentsAndCities(mode); maybeRenderStateGraph(); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; @@ -2666,28 +3100,6 @@ function drawTradeLinks() { ctx.restore(); } -function drawFarmlandRings(mode) { - if (mode !== "cities") return; - const w = sim.world; - ctx.save(); - ctx.globalAlpha = 0.78; - ctx.fillStyle = "#d9c45f"; - for (const city of sim.cities) { - const radius = Math.max(1, city.agriculturalRadius); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const d = Math.abs(dx) + Math.abs(dy); - if (d !== radius) continue; - const x = city.x + dx; - const y = city.y + dy; - if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; - if (w.terrain[w.idx(x, y)] !== Terrain.WATER) ctx.fillRect(x, y, 1, 1); - } - } - } - ctx.restore(); -} - function drawAgentsAndCities(mode) { ctx.save(); for (const city of sim.cities) { @@ -2886,6 +3298,7 @@ function updateStats(force = false) { els.ethnicities.textContent = livingEthnicities.length.toLocaleString(); els.cities.textContent = sim.cities.length.toLocaleString(); els.polities.textContent = sim.polities.length.toLocaleString(); + if (els.wars) els.wars.textContent = sim.wars.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})`; @@ -2907,8 +3320,6 @@ function setLegend() { 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") { - els.legend.innerHTML = "City nodes, farmland radius, and stored urban population"; } else if (mode === "polities") { els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent."; } else if (mode === "technology") { @@ -2946,7 +3357,9 @@ function renderStateGraph() { g.fillStyle = "#101315"; g.fillRect(0, 0, w, h); - const historyWindowStart = Math.max(0, sim.year - years(10000)); + const historyWindowSpan = years(SimConfig.render.historyWindowYears); + const historyWindowEnd = sim.year; + const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan); const histories = sim.getAllPolityHistories() .filter(history => (history.ended ?? sim.year) >= historyWindowStart) .map(history => ({ @@ -2963,13 +3376,11 @@ function renderStateGraph() { return; } - let minYear = Infinity; - let maxYear = sim.year; + let minYear = historyWindowStart; + let maxYear = historyWindowEnd; for (const history of histories) { minYear = Math.min(minYear, history.visibleFounded); - maxYear = Math.max(maxYear, history.ended ?? 0, history.visibleSamples[history.visibleSamples.length - 1]?.year ?? 0); } - if (!Number.isFinite(minYear)) minYear = 0; if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; const paddingLeft = 34;