diff --git a/app-state.js b/app-state.js index 2974c27..1dc2f68 100644 --- a/app-state.js +++ b/app-state.js @@ -41,9 +41,9 @@ scale: null }; const legendByMode = { terrain: () => terrainInfo.map(t => `${t.name}`).join(""), -ethnicity: () => "Agent and city colors show lineage. Land remains terrain-colored.", +ethnicity: () => "Color = tile-level dominant ethnicity. Stronger color marks higher local population; mixed areas are muted.", pressure: () => "High local population pressure", -polities: () => "Color = city-centered state. Uncolored cities are independent.", +polities: () => "Color = territorial state control. Brighter areas are stronger control; pale borders mark frontiers; contested areas are unstable.", technology: () => "Farming knowledgeMetallurgy knowledge", pheromone: () => "Pheromone strengthFormal trade routes", resources: () => "Regenerating local resource stock" diff --git a/config.js b/config.js index 9d22f69..40b42fe 100644 --- a/config.js +++ b/config.js @@ -36,9 +36,9 @@ maxCities: 300, agingStartYears: 180, agingDecayPerYear: 0.002, agingOverCapAttrition: 0.035, -agingRestoreBudgetShare: 0.18, +agingRestoreMinWear: 0.08, agingRestoreCostPerWear: 70, -agingRestoreMinWear: 0.08 +agingRestoreBudgetShare: 0.18 }), render: Object.freeze({ graphThrottleMs: 1400, @@ -55,12 +55,34 @@ polity: Object.freeze({ minimumPerCapitaFood: 0.06, logisticsDistance: 34, charismaMin: 0.5, -charismaMax: 3.0, -charismaAverage: 1.0 +charismaMax: 1.5, +charismaAverage: 1.0, +leaderTenureMinYears: 22, +leaderTenureMaxYears: 72 +}), +campaign: Object.freeze({ +checkIntervalYears: 3, +baseChance: 0.18, +minCooldownYears: 12, +maxCooldownYears: 45, +maxActiveCampaigns: 12, +frontierTargetChance: 0.35, +nonStateTargetChance: 0.30, +independentCityTargetChance: 0.20, +polityTargetChance: 0.15, +baseDurationYears: 8, +maxDurationYears: 25, +claimRadius: 8, +maxTargetDistance: 48 }), polityAccess: Object.freeze({ enabled: true, -noAccessPenalty: 0.17 +maxSearchDepth: 8, +indirectPenalty: 0.012, +perHopPenalty: 0.006, +noAccessPenalty: 0.045, +lowLoyaltyTransitPenalty: 0.012, +foreignTransitPenalty: 0.008 }), culture: Object.freeze({ spreadRadius: 3, diff --git a/engine.js b/engine.js index ee209d5..342fdc1 100644 --- a/engine.js +++ b/engine.js @@ -32,9 +32,19 @@ this.farmland = new Float32Array(this.count); this.cityPull = new Float32Array(this.count); this.city = new Int32Array(this.count); this.pressure = new Float32Array(this.count); +this.population = new Float32Array(this.count); +this.settledPopulation = new Float32Array(this.count); +this.mobilePopulation = new Float32Array(this.count); +this.populationCapacity = new Float32Array(this.count); +this.populationPressure = new Float32Array(this.count); +this.polity = new Int32Array(this.count); +this.control = new Float32Array(this.count); +this.claim = new Float32Array(this.count); +this.contested = new Uint8Array(this.count); this.dominantEthnicity = new Int32Array(this.count); this.cultureDiversity = new Float32Array(this.count); this.city.fill(-1); +this.polity.fill(-1); this.dominantEthnicity.fill(-1); this.generate(); } @@ -310,16 +320,24 @@ this.nextCity = 1; this.nextPolity = 1; this.nextWar = 1; this.nextDisaster = 1; +this.nextCampaign = 1; this.year = 0; this.deaths = 0; +this.campaigns = []; +this.campaignHistory = []; +this.territorialClaims = new Map(); +this.populationFieldInitialized = false; this.maxAgents = Math.max( Math.floor(initialAgents * SimConfig.population.maxAgentsScale), SimConfig.population.maxAgentsFloor ); +this.tileEthnicMix = new Map(); this.tileEthnicities = new Map(); +this.tileCultures = this.tileEthnicMix; this.tileAgents = new Map(); this.spawnInitialAgents(initialAgents); -this.rebuildOccupancy(); +this.seedInitialPopulationField(); +this.recomputeTerritories(); this.updateEthnicStats(); } spawnInitialAgents(count) { @@ -559,12 +577,21 @@ if (pressure <= softCapStart || this.rng.next() < acceptChance) acceptedOffsprin } this.agents.push(...acceptedOffspring); for (const child of acceptedOffspring) this.addAgentToOccupancy(child); +this.updatePopulationCapacity(); if (this.year % 2 === 0) this.updateWorldFields(2); this.maybeSpawnDisaster(); if (this.year % years(1) === 0) this.maybeSpawnFrontierWave(); if (this.year % years(1) === 0) { +this.blendAgentPresenceIntoPopulationField(0.015); this.updateCities(); -if (this.year % years(2) === 0) this.updateRegionalCultures(); +this.updatePopulationCapacity(); +this.consumeTileResources(); +this.growTilePopulation(); +this.diffusePopulation(); +if (this.year % years(3) === 0) this.updateTileAssimilation(); +this.updateRegionalCultures(); +this.recomputeTerritories(); +this.campaignPressureOnTerritories(); } if (this.year % years(5) === 0) { this.updateTradeRoutes(); @@ -600,7 +627,7 @@ this.tileEthnicities.set(i, counts); } counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); } -for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile); +if (!this.populationFieldInitialized) this.rebuildPopulationFieldsFromAgents(); } rebuildIndexes() { this.cityById = new Map(this.cities.map(city => [city.id, city])); @@ -636,8 +663,98 @@ if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city); } return cities; } +// Phase 2: transitional tile-level population and culture fields. +// Agents and cities still exist, but render/culture/state logic can read the +// aggregate population field and per-tile ethnic composition. +seedInitialPopulationField() { +this.rebuildOccupancy(); +this.rebuildPopulationFieldsFromAgents(true); +this.updatePopulationCapacity(); +this.populationFieldInitialized = true; +} +rebuildPopulationFieldsFromAgents(force = false) { +if (this.populationFieldInitialized && !force) return; +const w = this.world; +w.population.fill(0); +w.settledPopulation.fill(0); +w.mobilePopulation.fill(0); +w.populationPressure.fill(0); +this.tileEthnicMix.clear(); +this.tileCultures = this.tileEthnicMix; +for (const a of this.agents) { +if (!a.alive) continue; +const tile = w.idx(a.x, a.y); +const sedentary = clamp(a.traits?.sedentary ?? 0.5, 0, 1); +w.population[tile] += 1; +if (sedentary >= 0.55) w.settledPopulation[tile] += 1; +else w.mobilePopulation[tile] += 1; +let mix = this.tileEthnicMix.get(tile); +if (!mix) { +mix = new Map(); +this.tileEthnicMix.set(tile, mix); +} +mix.set(a.ethnicity, (mix.get(a.ethnicity) || 0) + 1); +} +for (const city of this.cities) this.projectCityPopulationToTiles(city); +this.syncTilePopulationCulture(); +} +syncTilePopulationCulture() { +const w = this.world; +this.tileCultures = this.tileEthnicMix; +for (const tile of this.tileEthnicMix.keys()) this.updateCultureTile(tile); +for (let i = 0; i < w.count; i++) { +if (!this.tileEthnicMix.has(i) && w.population[i] <= 0) { +w.dominantEthnicity[i] = -1; +w.cultureDiversity[i] *= 0.985; +} +} +this.updatePopulationCapacity(); +} +projectCityPopulationToTiles(city) { +if (!city || city.population <= 0) return; +const w = this.world; +const radius = clamp(Math.ceil((city.agriculturalRadius || 2) * 0.75), 1, 8); +let totalWeight = 0; +const tiles = []; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance > radius) continue; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const weight = (radius - distance + 1) * (tile === w.idx(city.x, city.y) ? 1.8 : 1); +tiles.push({ tile, weight }); +totalWeight += weight; +} +} +if (!tiles.length || totalWeight <= 0) return; +const cityTotal = compositionTotal(city.ethnicityComposition); +const fallbackEthnicity = dominantComposition(city.ethnicityComposition); +for (const entry of tiles) { +const share = entry.weight / totalWeight; +const localPopulation = city.population * share; +w.population[entry.tile] += localPopulation; +w.settledPopulation[entry.tile] += localPopulation; +let culture = this.tileEthnicMix.get(entry.tile); +if (!culture) { +culture = new Map(); +this.tileEthnicMix.set(entry.tile, culture); +} +if (cityTotal > 0) { +for (const [id, count] of city.ethnicityComposition) { +const amount = localPopulation * count / cityTotal; +if (amount > 0) culture.set(id, (culture.get(id) || 0) + amount); +} +} else if (fallbackEthnicity !== null) { +culture.set(fallbackEthnicity, (culture.get(fallbackEthnicity) || 0) + localPopulation); +} +} +} updateCultureTile(tile) { -const counts = this.tileEthnicities.get(tile); +const counts = this.tileEthnicMix.get(tile) || this.tileEthnicities.get(tile); if (!counts || !counts.size) { this.world.cultureDiversity[tile] *= 0.98; return; @@ -655,6 +772,222 @@ dominantCount = count; this.world.dominantEthnicity[tile] = dominant; this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0; } +diffusePopulation() { +const w = this.world; +this.updatePopulationCapacity(); +const moves = []; +for (let tile = 0; tile < w.count; tile++) { +const population = w.population[tile]; +if (population < 1 || w.populationPressure[tile] <= 1.05) continue; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let bestTile = -1; +let bestScore = -Infinity; +const radius = 2; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (!distance || distance > radius) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const target = w.idx(tx, ty); +const score = this.populationDestinationScore(tile, target); +if (score > bestScore) { +bestScore = score; +bestTile = target; +} +} +} +if (bestTile < 0 || bestScore <= 0) continue; +const excess = Math.max(0, population - w.populationCapacity[tile]); +const amount = Math.min(population * 0.035, excess * 0.18); +if (amount >= 0.05) moves.push({ from: tile, to: bestTile, amount }); +} +for (const move of moves) this.moveEthnicPopulation(move.from, move.to, move.amount); +this.updatePopulationCapacity(); +} +populationDestinationScore(fromTile, toTile) { +const w = this.world; +if (w.terrain[toTile] === Terrain.WATER) return -Infinity; +const fromDominant = w.dominantEthnicity[fromTile]; +const toDominant = w.dominantEthnicity[toTile]; +const sameEthnicity = fromDominant >= 0 && toDominant >= 0 && fromDominant === toDominant ? 1.1 : 0; +const routeScore = (w.tradeRoute[toTile] ? 1.25 : 0) + clamp(w.pheromone[toTile] / 8, 0, 1.4); +const overcapacity = Math.max(0, w.populationPressure[toTile] - 0.85); +const foreignPolity = w.polity[toTile] >= 0 && w.polity[fromTile] >= 0 && w.polity[toTile] !== w.polity[fromTile] ? 1.25 : 0; +return w.resource[toTile] * 0.06 + +w.fertility[toTile] * 1.4 + +w.mineral[toTile] * 0.35 + +w.cityPull[toTile] * 1.3 + +routeScore + +sameEthnicity - +w.move[toTile] * 0.62 - +overcapacity * 2.4 - +foreignPolity; +} +moveEthnicPopulation(fromTile, toTile, amount) { +const w = this.world; +const fromMix = this.tileEthnicMix.get(fromTile); +if (!fromMix || amount <= 0 || w.population[fromTile] <= 0) return 0; +const moved = Math.min(amount, w.population[fromTile]); +let toMix = this.tileEthnicMix.get(toTile); +if (!toMix) { +toMix = new Map(); +this.tileEthnicMix.set(toTile, toMix); +} +const total = compositionTotal(fromMix); +if (total <= 0) return 0; +for (const [id, count] of [...fromMix]) { +const ethnicMove = Math.min(count, moved * count / total); +if (ethnicMove <= 0) continue; +const remaining = count - ethnicMove; +if (remaining > 0.01) fromMix.set(id, remaining); +else fromMix.delete(id); +toMix.set(id, (toMix.get(id) || 0) + ethnicMove); +} +if (!fromMix.size) this.tileEthnicMix.delete(fromTile); +const settledShare = w.population[fromTile] > 0 ? w.settledPopulation[fromTile] / w.population[fromTile] : 0; +const settledMove = moved * clamp(settledShare, 0, 1); +const mobileMove = moved - settledMove; +w.population[fromTile] = Math.max(0, w.population[fromTile] - moved); +w.population[toTile] += moved; +w.settledPopulation[fromTile] = Math.max(0, w.settledPopulation[fromTile] - settledMove); +w.mobilePopulation[fromTile] = Math.max(0, w.mobilePopulation[fromTile] - mobileMove); +w.settledPopulation[toTile] += settledMove; +w.mobilePopulation[toTile] += mobileMove; +this.updateCultureTile(fromTile); +this.updateCultureTile(toTile); +return moved; +} +growTilePopulation() { +const w = this.world; +this.updatePopulationCapacity(); +for (let tile = 0; tile < w.count; tile++) { +const population = w.population[tile]; +if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue; +const pressure = w.populationPressure[tile]; +const baseGrowth = 0.004 + w.fertility[tile] * 0.010 + w.farmland[tile] * 0.007 + clamp(w.resource[tile] / 40, 0, 1) * 0.004; +const pressurePenalty = pressure > 1 ? (pressure - 1) * 0.020 : 0; +const warPenalty = w.contested[tile] ? 0.006 : 0; +const rate = clamp(baseGrowth - pressurePenalty - warPenalty, -0.045, 0.035); +const change = population * rate; +if (Math.abs(change) < 0.01) continue; +if (change > 0) this.addEthnicPopulationProportionally(tile, change); +else this.removeEthnicPopulationProportionally(tile, -change); +} +this.updatePopulationCapacity(); +} +addEthnicPopulationProportionally(tile, amount) { +const w = this.world; +let mix = this.tileEthnicMix.get(tile); +if (!mix) { +const dominant = w.dominantEthnicity[tile]; +if (dominant < 0) return; +mix = new Map([[dominant, amount]]); +this.tileEthnicMix.set(tile, mix); +w.population[tile] += amount; +w.settledPopulation[tile] += amount; +this.updateCultureTile(tile); +return; +} +const total = Math.max(0.001, compositionTotal(mix)); +for (const [id, count] of [...mix]) mix.set(id, count + amount * count / total); +w.population[tile] += amount; +w.settledPopulation[tile] += amount; +this.updateCultureTile(tile); +} +removeEthnicPopulationProportionally(tile, amount) { +const w = this.world; +const mix = this.tileEthnicMix.get(tile); +if (!mix) return; +const removed = Math.min(amount, w.population[tile]); +const total = Math.max(0.001, compositionTotal(mix)); +for (const [id, count] of [...mix]) { +const loss = removed * count / total; +const remaining = count - loss; +if (remaining > 0.01) mix.set(id, remaining); +else mix.delete(id); +} +if (!mix.size) this.tileEthnicMix.delete(tile); +const settledShare = w.population[tile] > 0 ? w.settledPopulation[tile] / w.population[tile] : 0; +w.population[tile] = Math.max(0, w.population[tile] - removed); +w.settledPopulation[tile] = Math.max(0, w.settledPopulation[tile] - removed * settledShare); +w.mobilePopulation[tile] = Math.max(0, w.mobilePopulation[tile] - removed * (1 - settledShare)); +this.updateCultureTile(tile); +} +consumeTileResources() { +const w = this.world; +for (let tile = 0; tile < w.count; tile++) { +const population = w.population[tile]; +if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue; +const pressure = w.populationPressure[tile] || 0; +const demand = population * (0.0045 + Math.max(0, pressure - 1) * 0.0035); +w.resource[tile] = Math.max(0, w.resource[tile] - demand); +} +} +blendAgentPresenceIntoPopulationField(weight = 0.01) { +if (!this.populationFieldInitialized || weight <= 0 || !this.agents.length) return; +const w = this.world; +for (const a of this.agents) { +if (!a.alive) continue; +const tile = w.idx(a.x, a.y); +const amount = weight; +w.population[tile] += amount; +if ((a.traits?.sedentary ?? 0.5) >= 0.55) w.settledPopulation[tile] += amount; +else w.mobilePopulation[tile] += amount; +let mix = this.tileEthnicMix.get(tile); +if (!mix) { +mix = new Map(); +this.tileEthnicMix.set(tile, mix); +} +mix.set(a.ethnicity, (mix.get(a.ethnicity) || 0) + amount); +this.updateCultureTile(tile); +} +this.updatePopulationCapacity(); +} +updateTileAssimilation() { +const w = this.world; +for (const [tile, mix] of [...this.tileEthnicMix]) { +if (!mix || mix.size <= 1) continue; +const total = compositionTotal(mix); +if (total <= 0.5) continue; +let dominant = null; +let dominantCount = 0; +for (const [id, count] of mix) { +if (count > dominantCount) { +dominant = id; +dominantCount = count; +} +} +if (dominant == null || dominantCount <= 0) continue; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const nearCity = this.getCitiesNear(x, y, 5).length ? 1 : 0; +const control = clamp(w.control[tile] || 0, 0, 1); +const route = w.tradeRoute[tile] ? 1 : 0; +const diversity = clamp(w.cultureDiversity[tile] || 0, 0, 1); +const dominantTraits = this.ethnicities.get(dominant)?.averageTraits; +const assimilation = dominantTraits?.assimilation ?? 0.12; +const baseRate = (0.0004 + nearCity * 0.00045 + control * 0.00035 + route * 0.00035 + diversity * 0.00025) * (0.5 + assimilation); +let gained = 0; +for (const [id, count] of [...mix]) { +if (id === dominant || count <= 0) continue; +const minorityTraits = this.ethnicities.get(id)?.averageTraits; +const resistance = minorityTraits?.ethnocentrism ?? 0.35; +const converted = Math.min(count, count * baseRate * clamp(1.15 - resistance * 0.55, 0.2, 1.15)); +if (converted <= 0.001) continue; +const remaining = count - converted; +if (remaining > 0.01) mix.set(id, remaining); +else mix.delete(id); +gained += converted; +} +if (gained > 0) { +mix.set(dominant, (mix.get(dominant) || 0) + gained); +this.updateCultureTile(tile); +} +} +} updateRegionalCultures() { const w = this.world; const nextDominant = new Int32Array(w.dominantEthnicity); @@ -677,7 +1010,7 @@ 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 pressureWeight = Math.sqrt(Math.max(0, w.population[source])); const cityWeight = w.city[source] >= 0 ? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight : 0; @@ -710,6 +1043,7 @@ if (!a.alive) return; const w = this.world; const i = w.idx(a.x, a.y); w.pressure[i]++; +if (!this.populationFieldInitialized) w.population[i]++; let agents = this.tileAgents.get(i); if (!agents) { agents = []; @@ -722,12 +1056,21 @@ counts = new Map(); this.tileEthnicities.set(i, counts); } counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); +let culture = this.tileEthnicMix.get(i); +if (!this.populationFieldInitialized) { +if (!culture) { +culture = new Map(); +this.tileEthnicMix.set(i, culture); +} +culture.set(a.ethnicity, (culture.get(a.ethnicity) || 0) + 1); 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); +if (!this.populationFieldInitialized) w.population[i] = Math.max(0, w.population[i] - 1); const agents = this.tileAgents.get(i); if (agents) { const index = agents.indexOf(a); @@ -741,8 +1084,17 @@ if (next > 0) counts.set(a.ethnicity, next); else counts.delete(a.ethnicity); if (!counts.size) this.tileEthnicities.delete(i); } +const culture = this.tileEthnicMix.get(i); +if (!this.populationFieldInitialized) { +if (culture) { +const next = (culture.get(a.ethnicity) || 0) - 1; +if (next > 0) culture.set(a.ethnicity, next); +else culture.delete(a.ethnicity); +if (!culture.size) this.tileEthnicMix.delete(i); +} this.updateCultureTile(i); } +} changeAgentEthnicity(a, nextEthnicity) { if (a.ethnicity === nextEthnicity) return; const w = this.world; @@ -754,6 +1106,15 @@ if (prev > 0) counts.set(a.ethnicity, prev); else counts.delete(a.ethnicity); counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1); } +const culture = this.tileEthnicMix.get(i); +if (!this.populationFieldInitialized) { +if (culture) { +const prev = (culture.get(a.ethnicity) || 0) - 1; +if (prev > 0) culture.set(a.ethnicity, prev); +else culture.delete(a.ethnicity); +culture.set(nextEthnicity, (culture.get(nextEthnicity) || 0) + 1); +} +} a.ethnicity = nextEthnicity; this.updateCultureTile(i); } @@ -871,13 +1232,34 @@ if (t === Terrain.DESERT) return 0.25; return 0.0; } carryingCapacityAt(tile) { +return this.carryingCapacityAtTile(tile); +} +carryingCapacityAtTile(tile) { const w = this.world; -if (w.terrain[tile] === Terrain.WATER) return 1.2; -return SimConfig.population.carryingCapacityBase + +if (w.terrain[tile] === Terrain.WATER) return 0.35; +const terrainFactor = +w.terrain[tile] === Terrain.MOUNTAIN ? 0.62 : +w.terrain[tile] === Terrain.DESERT ? 0.55 : +w.terrain[tile] === Terrain.FOREST ? 0.92 : +1; +const routeBonus = w.tradeRoute[tile] ? 1.6 : clamp(w.pheromone[tile] / Math.max(1, SimConfig.route.maxPheromone), 0, 1) * 0.8; +const cityBonus = w.city[tile] >= 0 ? 3.5 : w.cityPull[tile] * 2.2; +return Math.max(0.1, ( +SimConfig.population.carryingCapacityBase + 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); +routeBonus + +cityBonus +) * terrainFactor); +} +updatePopulationCapacity() { +const w = this.world; +for (let i = 0; i < w.count; i++) { +const capacity = this.carryingCapacityAtTile(i); +w.populationCapacity[i] = capacity; +w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0; +} } updateAgentTechnology(agent, payMaintenance = false) { if (!agent.alive) return; @@ -1252,7 +1634,7 @@ if (Math.abs(dx) + Math.abs(dy) > 2) continue; const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -const counts = this.tileEthnicities.get(w.idx(tx, ty)); +const counts = this.tileEthnicMix.get(w.idx(tx, ty)) || this.tileEthnicities.get(w.idx(tx, ty)); if (!counts) continue; for (const [id, value] of counts) { if (id === ethnicity) same += value; @@ -1301,7 +1683,7 @@ if (Math.abs(dx) + Math.abs(dy) > 3) continue; const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -const tileCounts = this.tileEthnicities.get(w.idx(tx, ty)); +const tileCounts = this.tileEthnicMix.get(w.idx(tx, ty)) || this.tileEthnicities.get(w.idx(tx, ty)); if (!tileCounts) continue; for (const [id, count] of tileCounts) { total += count; @@ -1372,43 +1754,52 @@ w.city.fill(-1); w.farmland.fill(0); w.cityPull.fill(0); const candidates = new Map(); -for (const a of this.agents) { -if (!a.alive) continue; -const local = w.idx(a.x, a.y); -if (a.settled < 3 && w.pressure[local] < 2) continue; -if (w.terrain[local] === Terrain.WATER) continue; -const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1); -const cy = clamp(Math.round(a.y / 4) * 4, 0, w.size - 1); +this.updatePopulationCapacity(); +for (let tile = 0; tile < w.count; tile++) { +if (w.terrain[tile] === Terrain.WATER) continue; +const population = w.population[tile] || 0; +const settled = w.settledPopulation[tile] || 0; +if (settled < 5 && population < 9) continue; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +if (this.getCitiesNear(x, y, 5).length) continue; +const cx = clamp(Math.round(x / 4) * 4, 0, w.size - 1); +const cy = clamp(Math.round(y / 4) * 4, 0, w.size - 1); const i = w.idx(cx, cy); let group = candidates.get(i); if (!group) { -group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map() }; +group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map(), sourceTiles: [] }; candidates.set(i, group); } -this.ensureAgentTech(a); -group.count++; -group.resources += Math.max(0, a.resources); -group.sedentary += a.traits.sedentary; -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); +const localWeight = 0.65 + w.fertility[tile] * 0.35 + w.cityPull[tile] * 0.25 + (w.tradeRoute[tile] ? 0.2 : 0); +group.count += population * localWeight; +group.resources += Math.max(0, w.resource[tile]) * Math.max(0.25, population * 0.06); +group.sedentary += settled; +group.farming += w.farmland[tile] * settled; +group.metallurgy += w.mineral[tile] * population; +group.techCount += Math.max(1, population); +group.sourceTiles.push(tile); +const mix = this.tileEthnicMix.get(tile); +if (mix) { +for (const [id, count] of mix) group.ethnicities.set(id, (group.ethnicities.get(id) || 0) + count); +} } let foundedThisTick = 0; const canFoundCities = this.year >= years(80) && this.year % years(10) === 0; const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0; const candidateEntries = [...candidates].sort((a, b) => { -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); +const scoreA = this.cityFoundingScore(a[0], a[1]); +const scoreB = this.cityFoundingScore(b[0], b[1]); +return scoreB - scoreA; }); for (const [i, group] of candidateEntries) { -if (group.count < 3) continue; -const avgSedentary = group.sedentary / group.count; +if (group.count < 8) continue; +const avgSedentary = clamp(group.sedentary / Math.max(1, group.count), 0, 1); let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null; if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { -const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98); -if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue; +const foundingScore = this.cityFoundingScore(i, group); +const foundingChance = clamp((foundingScore - 6) * 0.055, 0.03, 0.72); +if (avgSedentary < 0.28 || foundingScore < 6 || this.rng.next() > foundingChance) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); this.cities.push(city); this.cityById.set(city.id, city); @@ -1464,19 +1855,47 @@ w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1)); return true; }); this.rebuildIndexes(); +this.updatePopulationCapacity(); +} +cityFoundingScore(tile, group) { +const w = this.world; +const settledShare = group.count > 0 ? group.sedentary / group.count : 0; +const pressure = w.populationPressure[tile] || 0; +const route = w.tradeRoute[tile] ? 1.4 : clamp(w.pheromone[tile] / 10, 0, 1.2); +const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), 10).length * 4; +return group.count * (0.45 + settledShare) + +pressure * 2.2 + +w.fertility[tile] * 3.4 + +clamp(w.resource[tile] / 18, 0, 3) + +route + +w.mineral[tile] * 0.9 - +w.move[tile] * 1.1 - +nearbyPenalty; +} +drawPopulationFromTilesForCity(city, group, amount) { +const w = this.world; +let remaining = Math.max(0, amount); +for (const tile of group?.sourceTiles || [w.idx(city.x, city.y)]) { +if (remaining <= 0) break; +const available = Math.max(0, w.settledPopulation[tile] || w.population[tile] || 0); +if (available <= 0) continue; +const take = Math.min(available, remaining); +this.removeEthnicPopulationProportionally(tile, take); +remaining -= take; +} } createCity(x, y, seedGroup = null) { -const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10; +const seedPopulation = seedGroup ? Math.max(14, Math.floor(seedGroup.count * 0.42)) : 10; const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5; const composition = new Map(); if (seedGroup) { const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1); for (const [id, count] of seedGroup.ethnicities) { -const urbanCount = this.weightedUrbanContribution(count * 3, urbanWeight); +const urbanCount = this.weightedUrbanContribution(count * 0.42, urbanWeight); if (urbanCount > 0) composition.set(id, urbanCount); } } -return { +const city = { id: this.nextCity++, x, y, @@ -1500,6 +1919,10 @@ receivedAid: false, tradeValue: 0, tradeReach: 0 }; +if (seedGroup) this.drawPopulationFromTilesForCity(city, seedGroup, seedPopulation); +this.projectCityPopulationToTiles(city); +this.syncTilePopulationCulture(); +return city; } updateCityAging(city) { city.ageWear ??= 0; @@ -1514,7 +1937,7 @@ return clamp(1 - (city?.ageWear || 0), 0, 1); } cityAgePopulationLimit(city) { const peakPopulation = Math.max(city?.peakPopulation || 0, city?.population || 0, 1); -return Math.max(0, peakPopulation * this.cityAgeCapacityFactor(city)); +return Math.max(1, peakPopulation * this.cityAgeCapacityFactor(city)); } absorbUrbanPopulation() { if (!this.cities.length) return; @@ -1706,6 +2129,172 @@ if (!city || city.population <= 0 || city.strength <= 0) return 0; const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35); return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor; } +// Phase 1: administrative territory derived from city holdings. +// This does not expand states. Campaign events below change holdings; this +// pass only projects the visible control field from those holdings. +clearTerritories() { +const w = this.world; +w.polity.fill(-1); +w.control.fill(0); +w.claim.fill(0); +w.contested.fill(0); +} +recomputeTerritories() { +const w = this.world; +this.clearTerritories(); +if (!this.polities.length || !this.cities.length) return; +const best = new Float32Array(w.count); +const second = new Float32Array(w.count); +const owner = new Int32Array(w.count); +owner.fill(-1); +for (const polity of this.polities) { +const polityCities = this.getPolityCities(polity); +if (!polityCities.length) continue; +const center = this.getCityById(polity.centerCityId) || polityCities[0]; +const maxRange = clamp(Math.ceil(this.polityInfluenceRange(polity, false)), 8, 36); +for (const city of polityCities) { +const cityTile = w.idx(city.x, city.y); +const cityEthnicity = this.dominantCityEthnicity(city); +const capitalDistance = center ? this.effectiveDistance(center, city) : 0; +const capitalFactor = city.id === polity.centerCityId ? 1.28 : clamp(1 - capitalDistance / 96, 0.58, 1); +const loyaltyFactor = 0.62 + clamp(city.loyalty ?? 0.5, 0, 1) * 0.58; +const tradeFactor = 1 + clamp((city.tradeReach || 0) / 60, 0, 0.42) + clamp((city.tradeValue || 0) * 0.10, 0, 0.22); +const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor; +const radius = clamp(Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45))), 6, maxRange); +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const manhattan = Math.abs(dx) + Math.abs(dy); +if (manhattan > radius) continue; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +const isWater = w.terrain[tile] === Terrain.WATER; +if (isWater && tile !== cityTile && !w.tradeRoute[tile] && !this.territoryWaterAnchor(tile)) continue; +const routeBoost = w.tradeRoute[tile] ? 1.28 : 1; +const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1); +const effectiveDistance = (manhattan + terrainDrag * 1.4) / routeBoost; +const distanceFalloff = clamp(1 - effectiveDistance / (radius + 4), 0, 1); +if (distanceFalloff <= 0) continue; +const localEthnicity = w.dominantEthnicity[tile]; +const ethnicityFactor = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? 0.82 : 1.06; +const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 20, 0, 0.28); +const score = baseInfluence * distanceFalloff * distanceFalloff * routeBoost * ethnicityFactor * (1 + populationAnchor) / (1 + terrainDrag * 0.18); +if (score <= 0.8) continue; +if (score > best[tile]) { +if (owner[tile] !== polity.id) second[tile] = best[tile]; +best[tile] = score; +owner[tile] = polity.id; +} else if (owner[tile] !== polity.id && score > second[tile]) { +second[tile] = score; +} +} +} +} +} +const threshold = 2.4; +for (let i = 0; i < w.count; i++) { +if (owner[i] < 0 || best[i] < threshold) continue; +w.polity[i] = owner[i]; +w.claim[i] = clamp(best[i] / 22, 0, 1); +w.control[i] = clamp((best[i] - threshold) / 18, 0.12, 1); +if (second[i] > best[i] * 0.72) w.contested[i] = 1; +} +this.applyStoredTerritorialClaims(); +} +applyStoredTerritorialClaims() { +const w = this.world; +const validPolities = new Set(this.polities.map(polity => polity.id)); +for (const [tile, claim] of [...this.territorialClaims]) { +if (!validPolities.has(claim.polityId) || w.terrain[tile] === Terrain.WATER) { +this.territorialClaims.delete(tile); +continue; +} +w.polity[tile] = claim.polityId; +w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35); +w.control[tile] = Math.max(w.control[tile], claim.control || 0.25); +if (claim.contested) w.contested[tile] = 1; +} +} +territoryWaterAnchor(tile) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +for (const [dx, dy] of dirs) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const n = w.idx(tx, ty); +if (w.city[n] >= 0 || w.tradeRoute[n] > 0) return true; +} +return false; +} +isBorderTile(tile) { +const w = this.world; +const polityId = w.polity[tile]; +if (polityId < 0) return false; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +for (const [dx, dy] of dirs) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +if (w.polity[w.idx(tx, ty)] !== polityId) return true; +} +return false; +} +territoryStatsForPolity(polity) { +const w = this.world; +const stats = { tiles: 0, population: 0, coreTiles: 0, contestedTiles: 0, borderTiles: 0 }; +if (!polity) return stats; +for (let i = 0; i < w.count; i++) { +if (w.polity[i] !== polity.id) continue; +stats.tiles++; +stats.population += w.population?.[i] || 0; +if (w.control[i] >= 0.55 && !w.contested[i]) stats.coreTiles++; +if (w.contested[i]) stats.contestedTiles++; +if (this.isBorderTile(i)) stats.borderTiles++; +} +if (stats.population <= 0) { +stats.population = this.getPolityCities(polity).reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); +} +return stats; +} +campaignPressureOnTerritories() { +const w = this.world; +const cfg = SimConfig.campaign || {}; +const radius = cfg.claimRadius ?? 8; +for (const campaign of this.campaigns || []) { +if (campaign.status !== "active" || campaign.targetTile == null) continue; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) continue; +const cx = campaign.targetTile % w.size; +const cy = Math.floor(campaign.targetTile / w.size); +const pressure = clamp(0.10 + campaign.progress * 0.28, 0.08, 0.42); +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance > radius) continue; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const local = pressure * (1 - distance / (radius + 1)); +if (w.polity[tile] < 0 || w.polity[tile] === attacker.id) { +w.polity[tile] = attacker.id; +w.claim[tile] = Math.max(w.claim[tile], local); +w.control[tile] = Math.max(w.control[tile], local * 0.65); +} else { +w.claim[tile] = Math.max(w.claim[tile], local); +w.contested[tile] = 1; +} +} +} +} +} getCityById(id) { const cached = this.cityById?.get(id); if (cached) return cached; @@ -1760,19 +2349,55 @@ const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityA const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB); return from != null && to != null ? [from, to] : null; } +polityTradeNeighbors(city, polityId) { +if (!city || polityId == null) return []; +const neighbors = []; +for (const link of this.tradeLinks || []) { +const endpoints = this.tradeLinkEndpointIds(link); +if (!endpoints) continue; +const [from, to] = endpoints; +const otherId = from === city.id ? to : to === city.id ? from : null; +if (otherId == null) continue; +const other = this.getCityById(otherId); +if (other?.polityId === polityId) neighbors.push(other); +} +return neighbors; +} +tradeAccessToCenter(city, center, polityId) { +if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] }; +if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] }; +if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] }; +const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8; +const visited = new Set([city.id]); +const queue = [{ city, hops: 0, transitCities: [] }]; +while (queue.length) { +const current = queue.shift(); +if (current.hops >= maxDepth) continue; +for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) { +if (visited.has(neighbor.id)) continue; +const hops = current.hops + 1; +if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities }; +visited.add(neighbor.id); +queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] }); +} +} +return { reachable: false, hops: Infinity, transitCities: [] }; +} tradeAccessLoyaltyPenalty(city, center, polity) { const cfg = SimConfig.polityAccess; if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0; -return clamp(cfg.noAccessPenalty, 0, 0.24); +const access = this.tradeAccessToCenter(city, center, polity.id); +if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07); +let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty; +const originEthnicity = this.dominantCityEthnicity(city); +for (const transitCity of access.transitCities) { +if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty; +const transitEthnicity = this.dominantCityEthnicity(transitCity); +if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) { +penalty += cfg.foreignTransitPenalty; } -rollLeaderCharisma() { -const cfg = SimConfig.polity; -const min = cfg?.charismaMin ?? 0.5; -const max = cfg?.charismaMax ?? 3.0; -const targetAverage = cfg?.charismaAverage ?? 1.0; -const normalizedAverage = clamp((targetAverage - min) / Math.max(0.001, max - min), 0.001, 0.999); -const exponent = (1 / normalizedAverage) - 1; -return min + (max - min) * Math.pow(this.rng.next(), exponent); +} +return clamp(penalty, 0, 0.07); } effectiveDistance(cityA, cityB) { let distance = this.distanceBetweenCities(cityA, cityB); @@ -1785,14 +2410,16 @@ const polity = { id, centerCityId: centerCity.id, cityIds: new Set([centerCity.id]), +maxCityCount: 1, +everHadMultipleCities: false, treasury: Math.max(0, centerCity.storedResources * 0.12), color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), founded: this.year, legitimacy: this.rng.range(0.68, 0.94), cohesion: this.rng.range(0.58, 0.9), -charisma: this.rollLeaderCharisma(), +charisma: this.rng.range(SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5), leaderStarted: this.year, -leaderTenureYears: this.rng.range(24, 68), +leaderTenureYears: this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68), crisis: 0, lastCrisisYear: this.year }; @@ -1896,23 +2523,24 @@ while (this.graphEvents.length > 160) this.graphEvents.shift(); } selectNewLeader(polity, forced = false) { if (!polity) return; -const charismaMin = SimConfig.polity?.charismaMin ?? 0.5; -const charismaMax = SimConfig.polity?.charismaMax ?? 3.0; -const previousCharisma = clamp(polity.charisma ?? 1, charismaMin, charismaMax); +const minCharisma = SimConfig.polity.charismaMin ?? 0.5; +const maxCharisma = SimConfig.polity.charismaMax ?? 1.5; +const avgCharisma = SimConfig.polity.charismaAverage ?? 1; +const previousCharisma = clamp(polity.charisma ?? avgCharisma, minCharisma, maxCharisma); const center = this.getCityById(polity.centerCityId); const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5; const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3; -const randomLeader = this.rollLeaderCharisma(); +const randomLeader = this.rng.range(minCharisma, maxCharisma); const continuity = forced ? 0.18 : 0.34; const institutionalPull = 0.82 + institutionalBias * 0.36; const nextCharisma = clamp( previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, -charismaMin, -charismaMax +minCharisma, +maxCharisma ); polity.charisma = nextCharisma; polity.leaderStarted = this.year; -polity.leaderTenureYears = this.rng.range(22, 72); +polity.leaderTenureYears = this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 22, SimConfig.polity.leaderTenureMaxYears ?? 72); const change = nextCharisma - previousCharisma; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1); @@ -1920,9 +2548,9 @@ if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5); } updatePolityLeaders() { for (const polity of this.polities) { -polity.charisma = clamp(polity.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0); +polity.charisma = clamp(polity.charisma ?? (SimConfig.polity.charismaAverage ?? 1), SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5); polity.leaderStarted ??= polity.founded ?? this.year; -polity.leaderTenureYears ??= this.rng.range(24, 68); +polity.leaderTenureYears ??= this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68); const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR; const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears); const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09); @@ -2105,6 +2733,8 @@ city.polityId = polity.id; city.loyalty = clamp(initialLoyalty, 0, 1); city.receivedAid = false; polity.cityIds.add(city.id); +polity.maxCityCount = Math.max(polity.maxCityCount || 0, polity.cityIds.size); +if ((polity.maxCityCount || 0) > 1) polity.everHadMultipleCities = true; } removeCityFromPolity(city) { if (!city || city.polityId === null) return; @@ -2187,7 +2817,7 @@ tech * 24 - instability * 12 - agePressure * 6 ); -return basePower * clamp(polity.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0); +return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5); } polityInfluenceRange(polity, wartime = false) { const power = this.polityPower(polity); @@ -2331,27 +2961,15 @@ cohesionBuffer, ); } foundPolities() { +let formed = 0; for (const city of this.cities) { if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue; -const centerInfluence = this.cityInfluence(city); -let absorbed = 0; -for (const other of this.cities) { -if (absorbed >= 3) break; -if (other === city || other.polityId !== null) continue; -if (this.distanceBetweenCities(city, other) > 34) continue; -const targetInfluence = this.cityInfluence(other); -if (centerInfluence <= targetInfluence * 1.08) continue; -const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08); -const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0; -const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus; -if (dominanceScore > 0.68 && this.rng.next() < 0.32) { -const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId); -const connection = this.polityConnectionToCity(polity, other); -if (!connection.connected) continue; -this.addCityToPolity(other, polity, 0.68); -absorbed++; -} -} +if (formed >= 2) break; +const localPeers = this.getCitiesNear(city.x, city.y, 18).filter(other => other !== city && other.polityId === null); +const localLeader = localPeers.every(other => this.cityInfluence(city) >= this.cityInfluence(other) * 0.92); +if (!localLeader || this.rng.next() > 0.22) continue; +this.createPolity(city); +formed++; } } expandPolities() { @@ -2406,6 +3024,522 @@ absorbed++; } } } +// Phase 1B: campaigns are discrete targeted events. Influence/control can show +// continuous territory, but new holdings come from these active campaign objects. +maybeStartCampaigns() { +const cfg = SimConfig.campaign || {}; +const interval = years(cfg.checkIntervalYears ?? 3); +if (!interval || this.year % interval !== 0) return; +const active = (this.campaigns || []).filter(c => c.status === "active").length; +if (active >= (cfg.maxActiveCampaigns ?? 12)) return; +for (const polity of this.polities) { +if ((this.campaigns || []).filter(c => c.status === "active").length >= (cfg.maxActiveCampaigns ?? 12)) break; +if (!this.getPolityCities(polity).length) continue; +const cooldown = this.year - (polity.lastCampaignYear ?? -Infinity); +if (cooldown < years(cfg.minCooldownYears ?? 12)) continue; +if (polity.nextCampaignAllowedYear != null && this.year < polity.nextCampaignAllowedYear) continue; +const chance = this.campaignStartChance(polity); +if (this.rng.next() > chance) continue; +const target = this.chooseCampaignTarget(polity); +if (target) this.startCampaign(polity, target); +} +} +campaignStartChance(polity) { +const cfg = SimConfig.campaign || {}; +const power = this.polityPower(polity); +const powerFactor = clamp(power / 80, 0.5, 2.0); +const cohesion = clamp(polity.cohesion ?? 0.6, 0.25, 1.2); +const legitimacy = clamp(polity.legitimacy ?? 0.7, 0.25, 1.2); +const charisma = clamp(polity.charisma ?? 1, 0.5, 1.8); +const crisis = clamp(polity.crisis || 0, 0, 1.5); +const overextension = this.polityOverextension ? this.polityOverextension(polity) : 0; +const overextensionFactor = clamp(1.2 - overextension, 0.1, 1.2); +const crisisFactor = clamp(1.1 - crisis, 0.05, 1.1); +const treasuryFactor = clamp((polity.treasury || 0) / Math.max(8, this.getPolityCities(polity).length * 7), 0.45, 1.35); +const noise = this.rng.range(0.72, 1.24); +return clamp((cfg.baseChance ?? 0.18) * powerFactor * cohesion * legitimacy * charisma * overextensionFactor * crisisFactor * treasuryFactor * noise, 0, 0.68); +} +weightedPick(entries) { +const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.weight || 0), 0); +if (total <= 0) return null; +let roll = this.rng.next() * total; +for (const entry of entries) { +roll -= Math.max(0, entry.weight || 0); +if (roll <= 0) return entry; +} +return entries[entries.length - 1] || null; +} +chooseCampaignTarget(polity) { +const cfg = SimConfig.campaign || {}; +const groups = [ +{ kind: "frontier_colonization", weight: cfg.frontierTargetChance ?? 0.35, items: this.frontierCampaignTargets(polity) }, +{ kind: "nonstate_subjugation", weight: cfg.nonStateTargetChance ?? 0.30, items: this.nonStateCampaignTargets(polity) }, +{ kind: "independent_city_annexation", weight: cfg.independentCityTargetChance ?? 0.20, items: this.independentCityCampaignTargets(polity) }, +{ kind: "border_war", weight: cfg.polityTargetChance ?? 0.15, items: this.borderWarCampaignTargets(polity) } +].filter(group => group.items.length && group.weight > 0); +const group = this.weightedPick(groups); +if (!group) return null; +const target = this.weightedPick(group.items); +return target ? { ...target, type: group.kind } : null; +} +noisyTargetWeight(score) { +return Math.max(0, score * this.rng.range(0.45, 1.65) + this.rng.range(-0.08, 0.12)); +} +frontierCampaignTargets(polity) { +return this.sampleTileCampaignTargets(polity, (tile, connection) => { +const w = this.world; +if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; +const nearbyTrade = (w.tradeRoute[tile] ? 0.5 : 0) + clamp(w.pheromone[tile] / 12, 0, 0.45); +const resistance = Math.sqrt(Math.max(0, w.population[tile] || 0)) * 0.10; +const value = w.fertility[tile] + clamp(w.resource[tile] / 45, 0, 1) * 0.8 + nearbyTrade + w.mineral[tile] * 0.25 - resistance - connection.distance * 0.018 - w.move[tile] * 0.22; +if (value < 0.38) return null; +return { targetTile: tile, weight: this.noisyTargetWeight(value) }; +}); +} +nonStateCampaignTargets(polity) { +return this.sampleTileCampaignTargets(polity, (tile, connection) => { +const w = this.world; +if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; +const population = w.population?.[tile] || w.pressure[tile] || 0; +const ethnicity = w.dominantEthnicity[tile]; +if (population < 2 && ethnicity < 0) return null; +const diversity = w.cultureDiversity[tile] || 0; +const sourceEthnicity = this.dominantCityEthnicity(connection.source); +const cultureMismatch = sourceEthnicity !== null && ethnicity >= 0 && sourceEthnicity !== ethnicity ? 0.45 : 0; +const strategic = (w.tradeRoute[tile] ? 0.45 : 0) + w.mineral[tile] * 0.38 + w.fertility[tile] * 0.25; +const score = Math.sqrt(Math.max(1, population)) * 0.38 + strategic + clamp(w.resource[tile] / 45, 0, 1) * 0.35 - connection.distance * 0.016 - w.move[tile] * 0.22 - cultureMismatch + diversity * 0.12; +return { +targetTile: tile, +targetEthnicity: ethnicity >= 0 ? ethnicity : null, +weight: this.noisyTargetWeight(score) +}; +}); +} +independentCityCampaignTargets(polity) { +const cfg = SimConfig.campaign || {}; +const targets = []; +for (const city of this.cities) { +if (city.polityId !== null || city.population <= 0) continue; +const connection = this.polityConnectionToCity(polity, city); +if (!connection.connected || connection.distance > (cfg.maxTargetDistance ?? 48)) continue; +const cityInfluence = Math.max(1, this.cityInfluence(city)); +const proximity = 1 / (1 + connection.distance * 0.06); +const cityStrength = clamp(city.strength || 1, 0.2, 8) * 0.12; +const score = Math.sqrt(city.population || 1) * 0.12 + (city.tradeValue || 0) * 0.55 + proximity * 1.4 - connection.distance * 0.012 - cityStrength + this.polityPower(polity) / Math.max(1, cityInfluence) * 0.18; +targets.push({ +targetTile: this.world.idx(city.x, city.y), +targetCityId: city.id, +connection, +weight: this.noisyTargetWeight(score) +}); +} +return targets; +} +borderWarCampaignTargets(polity) { +const cfg = SimConfig.campaign || {}; +const targets = []; +for (const other of this.polities) { +if (other.id === polity.id || !this.canBeWarTarget(other.id)) continue; +const distance = this.polityDistance(polity, other); +if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48)) continue; +const border = this.nearestBorderCampaignTile(polity, other); +const city = this.borderWarTargetCity(polity, other); +const defenderPower = Math.max(1, this.polityPower(other)); +const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.18 + (city.tradeValue || 0) * 0.5 : 0.25; +const contested = border.tile != null && this.world.contested[border.tile] ? 0.75 : 0; +const borderValue = this.borderFriction(polity, other, distance) + contested + targetCityValue; +const riskAppetite = this.rng.next() < 0.10 ? this.rng.range(0.4, 1.1) : 0; +const score = borderValue - defenderPower / 90 - distance * 0.018 + this.polityPower(polity) / 120 + riskAppetite; +targets.push({ +targetTile: city ? this.world.idx(city.x, city.y) : border.tile, +targetCityId: city?.id ?? null, +defenderPolityId: other.id, +weight: this.noisyTargetWeight(score) +}); +} +return targets; +} +sampleTileCampaignTargets(polity, scorer) { +const w = this.world; +const cfg = SimConfig.campaign || {}; +const targets = []; +const seen = new Set(); +const cities = this.getPolityCities(polity); +const maxDistance = cfg.maxTargetDistance ?? 48; +const tries = clamp(80 + cities.length * 22, 90, 260); +for (let n = 0; n < tries; n++) { +const source = cities[this.rng.int(cities.length)]; +if (!source) break; +const radius = Math.ceil(maxDistance); +const x = clamp(source.x + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1); +const y = clamp(source.y + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1); +const tile = w.idx(x, y); +if (seen.has(tile)) continue; +seen.add(tile); +const connection = this.nearestPolityTileConnection(polity, tile); +if (!connection.source || connection.distance > maxDistance) continue; +const base = scorer(tile, connection); +if (!base) continue; +targets.push({ +...base, +connection, +weight: Math.max(0, base.weight || 0) / (1 + connection.distance * 0.035) +}); +} +return targets; +} +nearestPolityTileConnection(polity, tile) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let source = null; +let distance = Infinity; +for (const city of this.getPolityCities(polity)) { +let d = Math.abs(city.x - x) + Math.abs(city.y - y); +if (w.tradeRoute[tile]) d *= 0.72; +if (d < distance) { +source = city; +distance = d; +} +} +return { source, distance }; +} +nearestBorderCampaignTile(attacker, defender) { +const w = this.world; +let best = { tile: this.world.idx(this.getPolityCities(defender)[0]?.x || 0, this.getPolityCities(defender)[0]?.y || 0), distance: Infinity }; +for (let i = 0; i < w.count; i++) { +if (w.polity[i] !== defender.id || !this.isBorderTile(i)) continue; +const connection = this.nearestPolityTileConnection(attacker, i); +if (connection.distance < best.distance) best = { tile: i, distance: connection.distance }; +} +return best; +} +borderWarTargetCity(attacker, defender) { +const maxDistance = SimConfig.campaign?.maxTargetDistance ?? 48; +let best = null; +let bestDistance = Infinity; +for (const city of this.getPolityCities(defender)) { +const connection = this.polityConnectionToCity(attacker, city); +if (connection.connected && connection.distance < bestDistance && connection.distance <= maxDistance) { +best = city; +bestDistance = connection.distance; +} +} +return best; +} +startCampaign(polity, target) { +const cfg = SimConfig.campaign || {}; +const duration = years(Math.min(cfg.maxDurationYears ?? 25, (cfg.baseDurationYears ?? 8) + this.rng.int(Math.max(1, (cfg.maxDurationYears ?? 25) - (cfg.baseDurationYears ?? 8) + 1)))); +const targetTile = target.targetTile ?? (target.targetCityId ? this.cityTile(this.getCityById(target.targetCityId)) : null); +if (targetTile == null) return null; +const campaign = { +id: this.nextCampaign++, +type: target.type, +attackerPolityId: polity.id, +defenderPolityId: target.defenderPolityId ?? null, +targetCityId: target.targetCityId ?? null, +targetTile, +targetEthnicity: target.targetEthnicity ?? null, +started: this.year, +expires: this.year + duration, +progress: 0, +strength: 0, +resistance: 0, +status: "active" +}; +this.campaigns.push(campaign); +polity.lastCampaignYear = this.year; +polity.nextCampaignAllowedYear = this.year + years(this.rng.range(cfg.minCooldownYears ?? 12, cfg.maxCooldownYears ?? 45)); +if (campaign.type === "border_war") { +const defender = this.getPolityById(campaign.defenderPolityId); +if (defender && !this.getWarBetween(polity.id, defender.id)) this.startWar(polity, defender); +this.addPolityEvent(polity.id, "borderWar", this.year, this.campaignEventData(campaign, "started"), 2); +if (defender) this.addPolityEvent(defender.id, "borderWar", this.year, this.campaignEventData(campaign, "defending"), 2); +} else if (campaign.type === "frontier_colonization") { +this.addPolityEvent(polity.id, "expansion", this.year, this.campaignEventData(campaign, "started"), 1); +} +this.addPolityEvent(polity.id, "campaign", this.year, this.campaignEventData(campaign, "started"), 1); +return campaign; +} +cityTile(city) { +return city ? this.world.idx(city.x, city.y) : null; +} +updateCampaigns() { +if (this.year % years(1) !== 0) return; +for (const campaign of this.campaigns) { +if (campaign.status !== "active") continue; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) { +campaign.status = "failed"; +continue; +} +const force = this.campaignAttackerForce(campaign, attacker); +const resistance = this.campaignResistance(campaign, attacker); +campaign.strength = force; +campaign.resistance = resistance; +const momentum = (force - resistance) / Math.max(1, force + resistance); +campaign.progress = clamp(campaign.progress + 0.09 + momentum * 0.18 + this.rng.range(-0.08, 0.08), -0.35, 1.25); +attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(0.05, force * 0.018)); +if (campaign.progress >= 1) this.resolveCampaignSuccess(campaign); +else if (campaign.progress <= -0.28) this.resolveCampaignFailure(campaign); +else if (this.year >= campaign.expires) { +if (campaign.progress > 0.72 && this.rng.next() < 0.45) this.resolveCampaignSuccess(campaign); +else this.resolveCampaignFailure(campaign, "expired"); +} +} +this.campaigns = this.campaigns.filter(campaign => campaign.status === "active" || this.year - campaign.started < years(8)); +} +campaignAttackerForce(campaign, attacker) { +const connection = this.nearestPolityTileConnection(attacker, campaign.targetTile); +const source = connection.source; +const nearbyStrength = source ? this.cityInfluence(source) * 0.32 : 1; +const routeAccess = this.world.tradeRoute[campaign.targetTile] || (source && this.hasDirectTradeConnection(source, this.getCityById(campaign.targetCityId))) ? 1.18 : 1; +return Math.max(0.1, +this.polityPower(attacker) * 0.42 * +clamp(attacker.cohesion ?? 0.6, 0.2, 1.2) * +clamp(attacker.charisma ?? 1, 0.5, 1.5) * +clamp((attacker.treasury || 0) / Math.max(8, this.getPolityCities(attacker).length * 5), 0.25, 1.6) * +routeAccess + +nearbyStrength +); +} +campaignResistance(campaign, attacker) { +const w = this.world; +const tile = campaign.targetTile; +const targetCity = this.getCityById(campaign.targetCityId); +const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null; +const connection = this.nearestPolityTileConnection(attacker, tile); +const terrain = w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1); +const population = targetCity?.population || w.population[tile] || w.pressure[tile] || 0; +const defenderPower = defender ? this.polityPower(defender) * 0.55 : 0; +const attackerEthnicity = this.dominantCityEthnicity(connection.source); +const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : w.dominantEthnicity[tile]; +const mismatch = attackerEthnicity !== null && localEthnicity !== null && localEthnicity >= 0 && attackerEthnicity !== localEthnicity ? 1.22 : 0.92; +const foreignControl = w.polity[tile] >= 0 && w.polity[tile] !== attacker.id ? 1.35 : 1; +return Math.max(0.2, +(Math.sqrt(Math.max(1, population)) * 3.2 + terrain * 8 + connection.distance * 0.32 + defenderPower) * +mismatch * +foreignControl * +(1 + (w.cultureDiversity[tile] || 0) * 0.35) +); +} +resolveCampaignSuccess(campaign) { +campaign.status = "succeeded"; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) return; +if (campaign.type === "independent_city_annexation") { +const city = this.getCityById(campaign.targetCityId); +if (city) { +const similarity = this.campaignEthnicSimilarity(attacker, campaign); +if (city.polityId !== null && city.polityId !== attacker.id) this.removeCityFromPolity(city); +this.addCityToPolity(city, attacker, clamp(0.28 + similarity * 0.34 - campaign.resistance / Math.max(1, campaign.strength + campaign.resistance) * 0.14, 0.16, 0.68)); +} +this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, "succeeded"), 2); +} else if (campaign.type === "border_war") { +const defender = this.getPolityById(campaign.defenderPolityId); +const city = this.getCityById(campaign.targetCityId); +if (city && defender && city.polityId === defender.id) { +this.removeCityFromPolity(city); +this.addCityToPolity(city, attacker, 0.22); +} else { +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48); +} +if (defender) { +defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.04, 0, 1); +defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * 0.04); +} +this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2); +} else if (campaign.type === "nonstate_subjugation") { +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.56); +this.applySubjugationEffects(attacker, campaign); +this.addPolityEvent(attacker.id, "subjugation", this.year, this.campaignEventData(campaign, "succeeded"), 1); +} else { +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.46); +this.seedFrontierPopulation(attacker, campaign.targetTile); +this.maybeCreateFrontierSettlement(attacker, campaign.targetTile); +this.addPolityEvent(attacker.id, "expansion", this.year, this.campaignEventData(campaign, "succeeded"), 1); +this.addPolityEvent(attacker.id, "colonization", this.year, this.campaignEventData(campaign, "succeeded"), 1); +} +this.recordCampaign(attacker, campaign, campaign.status); +this.syncTilePopulationCulture(); +this.recomputeTerritories(); +this.campaignPressureOnTerritories(); +} +resolveCampaignFailure(campaign, reason = "failed") { +campaign.status = reason; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) return; +attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(1, campaign.strength * 0.04)); +attacker.cohesion = clamp((attacker.cohesion ?? 0.6) - 0.025, 0, 1); +attacker.legitimacy = clamp((attacker.legitimacy ?? 0.7) - 0.018, 0, 1); +if (campaign.type === "nonstate_subjugation") { +const w = this.world; +w.cultureDiversity[campaign.targetTile] = clamp((w.cultureDiversity[campaign.targetTile] || 0) + 0.10, 0, 1); +} +const war = campaign.defenderPolityId != null ? this.getWarBetween(attacker.id, campaign.defenderPolityId) : null; +if (war) { +if (war.aPolityId === attacker.id) war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); +else war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); +} +this.addPolityEvent(attacker.id, "failedCampaign", this.year, this.campaignEventData(campaign, reason), 1); +this.recordCampaign(attacker, campaign, campaign.status); +} +campaignEthnicSimilarity(polity, campaign) { +const source = this.nearestPolityTileConnection(polity, campaign.targetTile).source; +const targetCity = this.getCityById(campaign.targetCityId); +const a = this.dominantCityEthnicity(source); +const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.dominantEthnicity[campaign.targetTile]; +if (a === null || b === null || b < 0) return 0.45; +return a === b ? 1 : 0.25; +} +applyCampaignTerritory(polity, centerTile, radius, strength) { +const w = this.world; +const cx = centerTile % w.size; +const cy = Math.floor(centerTile / w.size); +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance > radius) continue; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const local = strength * (1 - distance / (radius + 1)); +w.polity[tile] = polity.id; +w.control[tile] = Math.max(w.control[tile], local); +w.claim[tile] = Math.max(w.claim[tile], local); +w.contested[tile] = 0; +this.territorialClaims.set(tile, { +polityId: polity.id, +control: Math.max(local, 0.18), +claim: Math.max(local, 0.22), +contested: false +}); +} +} +} +applySubjugationEffects(polity, campaign) { +const localEthnicity = campaign.targetEthnicity ?? this.world.dominantEthnicity[campaign.targetTile]; +if (localEthnicity == null || localEthnicity < 0) return; +const sourceEthnicity = this.dominantCityEthnicity(this.nearestPolityTileConnection(polity, campaign.targetTile).source); +if (sourceEthnicity === localEthnicity) return; +const x = campaign.targetTile % this.world.size; +const y = Math.floor(campaign.targetTile / this.world.size); +for (const city of this.getCitiesNear(x, y, 12)) { +if (city.polityId === polity.id) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.05, 0, 1); +} +} +seedFrontierPopulation(polity, tile) { +const source = this.nearestPolityTileConnection(polity, tile).source; +const ethnicity = this.dominantCityEthnicity(source) || this.world.dominantEthnicity[tile]; +if (!source || ethnicity == null || ethnicity < 0) return; +const migrants = Math.max(1, Math.floor(Math.min(source.population * 0.018, 14))); +source.population = Math.max(1, source.population - migrants); +removeFromComposition(source.ethnicityComposition, migrants); +this.world.population[tile] += migrants; +let culture = this.tileEthnicMix.get(tile); +if (!culture) { +culture = new Map(); +this.tileEthnicMix.set(tile, culture); +} +culture.set(ethnicity, (culture.get(ethnicity) || 0) + migrants); +} +maybeCreateFrontierSettlement(polity, tile) { +const w = this.world; +if (this.cities.length >= SimConfig.city.maxCities || w.city[tile] >= 0) return null; +if ((w.population[tile] || 0) < 8 || w.fertility[tile] + w.resource[tile] * 0.025 < 0.8) return null; +const target = { +type: "frontier_colonization", +tile, +x: tile % w.size, +y: Math.floor(tile / w.size), +population: w.population[tile] || 0, +connection: this.nearestPolityTileConnection(polity, tile) +}; +return this.foundCampaignOutpost(polity, target); +} +campaignEventData(campaign, outcome) { +return { +campaignId: campaign.id, +campaignType: campaign.type, +outcome, +targetCityId: campaign.targetCityId ?? null, +targetPolityId: campaign.defenderPolityId ?? null, +targetTile: campaign.targetTile, +progress: Number((campaign.progress || 0).toFixed(2)) +}; +} +recordCampaign(polity, campaign, outcome) { +const data = this.campaignEventData(campaign, outcome); +this.campaignHistory.push({ year: this.year, polityId: polity.id, ...data }); +while (this.campaignHistory.length > 160) this.campaignHistory.shift(); +} +foundCampaignOutpost(polity, target) { +const w = this.world; +const targetTile = target?.targetTile ?? target?.tile; +if (this.cities.length >= SimConfig.city.maxCities || !target || targetTile == null) return null; +const source = target.connection?.source || this.getCityById(polity.centerCityId); +if (!source) return null; +const isFrontier = target.type === "frontier_colonization" || target.type === "frontier"; +const cost = isFrontier ? 5.5 : 8.5; +if ((polity.treasury || 0) < cost && source.storedResources < cost * 1.4) return null; +const treasuryPaid = Math.min(polity.treasury || 0, cost); +polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryPaid); +source.storedResources = Math.max(0, source.storedResources - Math.max(0, cost - treasuryPaid) * 0.5); +const culture = this.tileCultures.get(targetTile); +const composition = new Map(); +let regionalTotal = 0; +if (culture?.size) { +for (const [id, count] of culture) regionalTotal += count; +const basePopulation = clamp(Math.round((target.population || 0) * 0.55), 6, 58); +for (const [id, count] of culture) { +const share = regionalTotal > 0 ? count / regionalTotal : 0; +if (share > 0) composition.set(id, Math.max(1, Math.round(basePopulation * share))); +} +} +if (!composition.size) { +const regionalEthnicity = this.world.dominantEthnicity[targetTile]; +const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1); +composition.set(sourceEthnicity, isFrontier ? 12 : 18); +} +const seedPopulation = Math.max(12, Math.round(compositionTotal(composition) * (isFrontier ? 1.2 : 1.6))); +const city = { +id: this.nextCity++, +x: target.x ?? targetTile % w.size, +y: target.y ?? Math.floor(targetTile / w.size), +population: seedPopulation, +storedResources: 18 + (w.resource[targetTile] || 0) * 0.25, +ethnicityComposition: composition, +pheromoneOutput: 0, +agriculturalRadius: 2, +tradeLinks: new Set(), +activeVisitors: 0, +age: 0, +ageWear: 0, +peakPopulation: seedPopulation, +strength: isFrontier ? 1.8 : 2.4, +sedentaryCulture: isFrontier ? 0.42 : 0.55, +knowledge: { ...source.knowledge }, +supplyStress: 0, +polityId: null, +loyalty: isFrontier ? 0.52 : 0.32, +receivedAid: false, +tradeValue: 0, +tradeReach: 0 +}; +this.cities.push(city); +this.cityById.set(city.id, city); +const bucketKey = this.cityBucketKey(city.x, city.y); +if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); +this.cityBuckets.get(bucketKey).push(city); +this.addCityToPolity(city, polity, city.loyalty); +w.city[targetTile] = city.id; +this.removeEthnicPopulationProportionally(targetTile, Math.min(seedPopulation, (w.population[targetTile] || 0) * 0.65)); +this.projectCityPopulationToTiles(city); +this.syncTilePopulationCulture(); +return city; +} maybeStartWars() { if (this.year % years(1) !== 0) return; for (const a of this.polities) { @@ -2672,34 +3806,6 @@ city.receivedAid = true; city.loyalty = clamp(city.loyalty + 0.05, 0, 1); } } -this.restoreAgedPolityCities(polity, cities); -} -} -restoreAgedPolityCities(polity, cities) { -const cfg = SimConfig.city; -if (!polity || !cities?.length || (polity.treasury || 0) <= 0) return; -const minWear = cfg.agingRestoreMinWear ?? 0.08; -let budget = Math.min(polity.treasury, (polity.treasury || 0) * (cfg.agingRestoreBudgetShare ?? 0.18)); -if (budget <= 0) return; -const costPerWear = Math.max(1, cfg.agingRestoreCostPerWear ?? 70); -const candidates = [...cities] -.filter(city => (city.ageWear || 0) > minWear && city.population > 0) -.sort((a, b) => { -const aPressure = Math.max(0, (a.population || 0) - this.cityAgePopulationLimit(a)); -const bPressure = Math.max(0, (b.population || 0) - this.cityAgePopulationLimit(b)); -return bPressure - aPressure || (b.population || 0) - (a.population || 0); -}); -for (const city of candidates) { -if (budget <= 0 || polity.treasury <= 0) break; -const wear = city.ageWear || 0; -const maxRestoreCost = Math.min(budget, polity.treasury, wear * costPerWear); -if (maxRestoreCost <= 0) continue; -const restoredWear = maxRestoreCost / costPerWear; -city.ageWear = clamp(wear - restoredWear, 0, 1); -polity.treasury -= maxRestoreCost; -budget -= maxRestoreCost; -city.receivedAid = true; -city.loyalty = clamp((city.loyalty ?? 0.5) + restoredWear * 0.24, 0, 1); } } erodePolityLegitimacy() { @@ -2788,7 +3894,8 @@ delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045); delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012; delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045); const directCenterTrade = this.hasDirectTradeConnection(city, center); -if (!directCenterTrade) delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); +if (directCenterTrade) delta += 0.025; +else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); if (city.receivedAid) delta += 0.04; delta -= 0.004; if (city.storedResources < city.population * 0.05) delta -= 0.035; @@ -2897,6 +4004,8 @@ cleanupPolities() { const survivors = []; for (const polity of this.polities) { const cities = this.getPolityCities(polity); +polity.maxCityCount = Math.max(polity.maxCityCount || 0, cities.length); +if ((polity.maxCityCount || 0) > 1) polity.everHadMultipleCities = true; if (!cities.length) { this.markPolityEnded(polity, "collapsed"); continue; @@ -2928,14 +4037,18 @@ const connectedCities = this.enforcePolityContinuity(polity); if (connectedCities.length !== cities.length) { cities.length = 0; cities.push(...connectedCities); +polity.maxCityCount = Math.max(polity.maxCityCount || 0, cities.length); } if (cities.length === 1) { -this.markPolityEnded(polity, "fragmented"); +const singleCityFate = this.singleCityPolityCollapseReason(polity, cities[0]); +if (singleCityFate) { +this.markPolityEnded(polity, singleCityFate); cities[0].polityId = null; cities[0].loyalty = 0.45; cities[0].receivedAid = false; continue; } +} survivors.push(polity); } this.polities = survivors; @@ -2949,11 +4062,24 @@ city.receivedAid = false; } } } +singleCityPolityCollapseReason(polity, city) { +if (!polity || !city) return "collapsed"; +const age = this.polityAge(polity); +if (age < 80) return null; +const lowPopulation = (city.population || 0) < 18; +const lowResources = (city.storedResources || 0) < Math.max(1, (city.population || 1) * 0.025); +const lowInstitutions = (polity.legitimacy ?? 0.7) < 0.18 && (polity.cohesion ?? 0.6) < 0.18; +const highCrisis = (polity.crisis || 0) > 1.05; +const cityFailing = city.strength <= 0.12 || city.loyalty < 0.08; +if ((lowPopulation && (lowResources || cityFailing)) || (lowInstitutions && highCrisis && lowResources)) { +return polity.everHadMultipleCities ? "fragmented" : "collapsed"; +} +return null; +} updatePolities() { if (this.year % years(1) !== 0) return; this.cleanupPolities(); this.foundPolities(); -this.expandPolities(); if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); this.collectAndRedistributeResources(); this.detectPolityFamines(); @@ -2962,12 +4088,15 @@ if (this.triggerPolityCrises) this.triggerPolityCrises(); if (this.applyOldStateStress) this.applyOldStateStress(); if (this.updatePolityLeaders) this.updatePolityLeaders(); this.updateCityLoyalty(); -this.absorbIndependentCities(); -this.maybeStartWars(); +this.maybeStartCampaigns(); +this.updateCampaigns(); this.updateWars(); this.splitUnloyalCities(); this.cleanupPolities(); if (this.samplePolityHistories) this.samplePolityHistories(); +this.syncTilePopulationCulture(); +this.recomputeTerritories(); +this.campaignPressureOnTerritories(); } reinforcePolityTradeRoutes() { for (const polity of this.polities) { diff --git a/render.js b/render.js index 4a6946b..a275814 100644 --- a/render.js +++ b/render.js @@ -10,7 +10,14 @@ const image = renderImage; const data = image.data; const mode = els.viewMode.value; const cityPolityColor = new Map(); +const polityColors = new Map(); if (mode === "polities") { +for (const polity of sim.polities) { +polityColors.set(polity.id, { +color: polity.color, +isGraphHover: graphState.hoverPolityId === polity.id +}); +} for (const city of sim.cities) { if (city.polityId === null) continue; const polity = cityPolity(city); @@ -29,15 +36,34 @@ const v = clamp(w.resource[i] / 30, 0, 1); color = mix([28, 36, 40], [107, 188, 85], v); } else if (mode === "ethnicity") { color = terrainInfo[w.terrain[i]].color; +const dominant = w.dominantEthnicity?.[i] ?? -1; +const ethnicityColor = dominant >= 0 ? sim.ethnicities.get(dominant)?.color : null; +if (ethnicityColor) { +const populationStrength = clamp(Math.sqrt(Math.max(0, w.population?.[i] || w.pressure[i] || 0)) / 16, 0.18, 0.68); +const diversityDimming = clamp(w.cultureDiversity?.[i] || 0, 0, 1) * 0.18; +color = mix(color, ethnicityColor, clamp(populationStrength - diversityDimming, 0.12, 0.64)); +} if (w.city[i] >= 0) { const city = sim.getCityById(w.city[i]); if (city) color = mix(color, cityMajorityColor(city), 0.72); } } else if (mode === "pressure") { -const v = clamp(w.pressure[i] / 12, 0, 1); +const pressure = w.populationPressure?.[i] ?? (w.pressure[i] / 12); +const v = clamp(pressure, 0, 1.6) / 1.6; color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v); } else if (mode === "polities") { color = terrainInfo[w.terrain[i]].color; +const territory = w.polity?.[i] ?? -1; +if (territory >= 0) { +const polityColor = polityColors.get(territory); +if (polityColor) { +const strength = clamp(0.16 + (w.control?.[i] || 0) * 0.38, 0.18, 0.55); +color = mix(color, polityColor.color, polityColor.isGraphHover ? Math.min(0.72, strength + 0.18) : strength); +if (w.contested?.[i]) color = mix(color, [128, 74, 50], 0.30); +if (sim.isBorderTile?.(i)) color = mix(color, [232, 225, 185], 0.22); +if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.30); +} +} if (w.city[i] >= 0) { const polityColor = cityPolityColor.get(w.city[i]); if (polityColor) { @@ -62,6 +88,7 @@ data[p + 3] = 255; ctx.putImageData(image, 0, 0); renderDisasters(); drawTradeLinks(); +drawCampaigns(); drawAgentsAndCities(mode); drawGraphPolityHighlight(); maybeRenderStateGraph(); @@ -137,6 +164,27 @@ ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); } ctx.restore(); } +function drawCampaigns() { +if (!sim?.campaigns?.length) return; +const w = sim.world; +ctx.save(); +for (const campaign of sim.campaigns) { +if (campaign.status !== "active" || campaign.targetTile == null) continue; +const x = campaign.targetTile % w.size; +const y = Math.floor(campaign.targetTile / w.size); +const age = Math.max(0, sim.year - campaign.started) / Math.max(1, campaign.expires - campaign.started); +const pulse = 0.5 + Math.sin(age * Math.PI * 8) * 0.5; +const radius = 2.5 + clamp(campaign.progress, 0, 1) * 4 + pulse * 1.5; +ctx.globalAlpha = 0.55; +ctx.strokeStyle = campaign.type === "border_war" ? "rgba(236, 126, 111, 0.95)" : "rgba(239, 211, 119, 0.95)"; +ctx.lineWidth = 1; +strokeCircle(ctx, x + 0.5, y + 0.5, radius); +ctx.globalAlpha = 0.22; +ctx.fillStyle = campaign.type === "nonstate_subjugation" ? "rgba(180, 111, 73, 0.9)" : "rgba(239, 211, 119, 0.9)"; +fillSquare(ctx, x, y, 1); +} +ctx.restore(); +} function drawAgentsAndCities(mode) { ctx.save(); if (mode !== "ethnicity") { @@ -161,12 +209,8 @@ ctx.fillStyle = rgba(color, clamp(0.32 + tech * 0.68, 0.32, 1)); ctx.fillRect(a.x, a.y, 1, 1); } } else if (mode === "ethnicity") { -for (const a of sim.agents) { -const e = sim.ethnicities.get(a.ethnicity); -if (!e) continue; -ctx.fillStyle = rgb(e.color); -ctx.fillRect(a.x, a.y, 1, 1); -} +// Phase 2: ethnicity view is driven by tile-level population/culture fields. +// Individual agents remain in the simulation but no longer define this layer. } else { ctx.fillStyle = "#eeeccf"; for (const tile of sim.tileAgents.keys()) { @@ -273,7 +317,11 @@ const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2); const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null; const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0; const polity = cityPolity(city); +const territoryId = w.polity?.[i] ?? -1; +const territoryPolity = territoryId >= 0 ? sim.getPolityById(territoryId) : null; const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; +const tilePopulation = w.population?.[i] || 0; +const nearbyCampaigns = activeCampaignsNear(i); els.tooltip.innerHTML = ` ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} ${tooltipSection("Tile", [ @@ -284,22 +332,30 @@ els.tooltip.innerHTML = ` tooltipRow("Minerals", w.mineral[i].toFixed(2)), tooltipRow("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`), tooltipRow("Water", waterInfluence), - tooltipRow("Pressure", w.pressure[i].toFixed(0)), + tooltipRow("Pressure", (w.populationPressure?.[i] ?? 0).toFixed(2)), + tooltipRow("Capacity", (w.populationCapacity?.[i] ?? 0).toFixed(1)), tooltipRow("Pheromone", w.pheromone[i].toFixed(1)), tooltipRow("Route", w.tradeRoute[i], w.tradeRoute[i]) ])} + ${tooltipSection("Territory", [ + tooltipRow("Territory", territoryPolity ? `#${territoryPolity.id}` : "Unclaimed"), + tooltipRow("Control", `${Math.round((w.control?.[i] || 0) * 100)}%`), + tooltipRow("Contested", w.contested?.[i] ? "yes" : "no"), + tooltipRow("Campaign", nearbyCampaigns, nearbyCampaigns), + tooltipRow("Dominant ethnicity", regionalEthnicity), + tooltipRow("Tile population", tilePopulation >= 10 ? Math.round(tilePopulation).toLocaleString() : tilePopulation.toFixed(1)) + ])} ${tooltipSection("City & State", [ tooltipRow("Population", city?.population.toLocaleString(), city), - tooltipRow("Age cap", city ? `${Math.floor(sim.cityAgePopulationLimit(city)).toLocaleString()} / ${Math.round((city.ageWear || 0) * 100)}% wear` : "", city), tooltipRow("Food stock", city?.storedResources.toFixed(1), city), tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city), tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city), tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city), - tooltipRow("City majority", cityEthnicity != null ? `E${cityEthnicity}` : "", cityEthnicity != null), + tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity), city ? cityEthnicityPie(city) : "", tooltipRow("State", polity ? `#${polity.id}` : "Independent", city), tooltipRow("Loyalty", city?.loyalty.toFixed(2), city), - tooltipRow("Charisma", clamp(polity?.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0).toFixed(2), polity), + tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity), tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity), tooltipRow("Treasury", polity?.treasury.toFixed(1), polity), tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity) @@ -329,44 +385,33 @@ els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.widt els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; } function cityEthnicityPie(city) { -if (!city) return ""; -let entries = [...(city.ethnicityComposition || new Map())] -.map(([id, count]) => [id, Number(count)]) -.filter(([, count]) => Number.isFinite(count) && count > 0) +if (!city?.ethnicityComposition?.size) return ""; +const entries = [...city.ethnicityComposition] +.filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]); -if (!entries.length && city.population > 0) { -const tile = sim.world.idx(city.x, city.y); -const fallbackId = sim.world.dominantEthnicity[tile] > 0 ? sim.world.dominantEthnicity[tile] : null; -if (fallbackId != null) entries = [[fallbackId, city.population]]; -} const total = entries.reduce((sum, [, count]) => sum + count, 0); if (total <= 0) return ""; const top = entries.slice(0, 5); const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0); const slices = other > 0 ? [...top, [0, other]] : top; -const sliceColor = id => { -const ethnicity = id !== 0 ? sim.ethnicities.get(id) : null; -return ethnicity?.color || [122, 130, 126]; -}; let cursor = 0; const gradient = slices.map(([id, count]) => { const start = cursor / total * 100; cursor += count; const end = cursor / total * 100; -const color = sliceColor(id); +const ethnicity = id ? sim.ethnicities.get(id) : null; +const color = ethnicity?.color || [122, 130, 126]; return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`; }).join(", "); const labels = slices.map(([id, count]) => { -const color = sliceColor(id); -const label = id !== 0 ? `E${id}` : "Other"; +const ethnicity = id ? sim.ethnicities.get(id) : null; +const color = ethnicity?.color || [122, 130, 126]; +const label = id ? `E${id}` : "Other"; return `${label} ${Math.round(count / total * 100)}%`; }).join(""); -const background = slices.length === 1 -? `rgb(${sliceColor(slices[0][0]).join(",")})` -: `conic-gradient(${gradient})`; return `