diff --git a/index.html b/index.html index 96574af..31e31b3 100644 --- a/index.html +++ b/index.html @@ -55,6 +55,7 @@ + diff --git a/script.js b/script.js index 36d8474..9a9920e 100644 --- a/script.js +++ b/script.js @@ -16,7 +16,9 @@ const terrainInfo = [ { name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 }, { name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 } ]; +const WEEKS_PER_MONTH = 4; const MONTHS_PER_YEAR = 12; +const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH; const SAVE_KEY = "civil-emergence-save"; const SAVE_VERSION = 3; const MAX_SAVE_BYTES = 4_500_000; @@ -61,11 +63,23 @@ cityWeight: 0.035, routeWeight: 1.35, minimumInfluence: 0.18 }), +route: Object.freeze({ +maxRouteLength: 42, +pheromoneDecay: 0.996, +pheromoneDiffusion: 0.045, +maxPheromone: 18, +routePheromoneBuild: 0.18, +routePheromoneMaintain: 0.12, +routeUpkeepPerTile: 0.018, +routeWeakThreshold: 8, +routeUnsupportedDecay: 7, +routeMaintainedBoost: 10 +}), disaster: Object.freeze({ damageScale: 1.5, majorLossRate: 0.05, checkIntervalYears: 8, -baseChance: 0.38, +baseChance: 0.76, minRadius: 6, maxRadius: 34, rareLargeChance: 0.10, @@ -79,7 +93,7 @@ maxHistory: 160 }) }); function years(value) { -return value * MONTHS_PER_YEAR; +return value * WEEKS_PER_YEAR; } const els = { canvas: document.getElementById("world"), @@ -556,7 +570,11 @@ farming: 0, metallurgy: 0 }, farmingWork: 0, -lastFarmTile: -1 +lastFarmTile: -1, +tradeOriginCityId: null, +lastTradeCityId: null, +tradeMemory: 0, +tradeCooldown: 0 }; } findNearbySpawn(originX, originY, preferredTerrain = null) { @@ -624,6 +642,7 @@ this.dominantEthnicityCache.clear(); for (const a of this.agents) { if (!a.alive) continue; this.updateAgentTechnology(a); +this.updateAgentTrade(a); this.gatherConsumeReproduce(a, offspring); if (a.alive) this.payTechnologyCostOrForget(a); if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a); @@ -864,13 +883,14 @@ const ethnicDensity = ethnocentrism > 0.04 ? this.ethnicDensityNear(x, y, a.ethnicity) : { same: 0, foreign: 0 }; 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 routeStrength = clamp(w.tradeRoute[i] / 80, 0, 1.8); +const routePull = (w.tradeRoute[i] ? 1.55 + routeStrength * 0.55 : clamp(w.pheromone[i] / 8, 0, 1)) * (0.35 + a.traits.mobility * 0.55 + sedentary * 0.45); const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0); 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 routeBonus = w.tradeRoute[i] ? 1.35 + routeStrength * 0.45 : 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; @@ -905,8 +925,8 @@ a.movedThisStep = true; a.farmingWork = 0; a.lastFarmTile = to; const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1; -w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale; -w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale; +this.addPheromone(from, (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale); +this.addPheromone(to, (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale); a.settled = Math.max(0, a.settled - 1); } else { a.movedThisStep = false; @@ -923,6 +943,19 @@ a.farmingWork ??= 0; a.lastFarmTile ??= -1; a.movedThisStep ??= false; } +ensureAgentTrade(agent) { +agent.tradeOriginCityId ??= null; +agent.lastTradeCityId ??= null; +agent.tradeMemory ??= 0; +agent.tradeCooldown ??= 0; +agent.tradeMemory = clamp(agent.tradeMemory, 0, 1); +agent.tradeCooldown = Math.max(0, Math.floor(agent.tradeCooldown)); +} +agentNomadism(agent) { +const mobility = clamp(agent.traits?.mobility ?? 0.5, 0, 1); +const sedentary = clamp(agent.traits ? getSedentary(agent.traits) : 0.5, 0, 1); +return clamp(mobility * 0.75 + (1 - sedentary) * 0.55, 0, 1); +} farmabilityAt(tile) { const t = this.world.terrain[tile]; if (t === Terrain.FERTILE) return 1.0; @@ -1037,6 +1070,7 @@ const w = this.world; const tile = w.idx(agent.x, agent.y); const assimilation = agent.traits.assimilation || 0; const ethnocentrism = agent.traits.ethnocentrism || 0; +const nomadism = this.agentNomadism(agent); let checked = 0; this.forEachLocalAgentNear(agent.x, agent.y, 1, other => { if (other === agent || !other.alive) return true; @@ -1051,6 +1085,7 @@ ethnocentrism * 0.0012; if (sameEthnicity) chance += 0.004; if (w.tradeRoute[tile]) chance += 0.006; if (w.city[tile] >= 0) chance += 0.003; +chance += nomadism * 0.004; chance = clamp(chance, 0.0008, 0.028); this.learnTechnologyFrom(agent, other, "farming", chance); this.learnTechnologyFrom(agent, other, "metallurgy", chance); @@ -1075,6 +1110,111 @@ agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1); } } +updateAgentTrade(agent) { +this.ensureAgentTrade(agent); +this.ensureAgentTech(agent); +if (agent.tradeCooldown > 0) { +agent.tradeCooldown--; +return; +} +const city = this.encounteredCityForAgent(agent); +if (!city) return; +const nomadism = this.agentNomadism(agent); +let chance = +0.015 + +nomadism * 0.08 + +(agent.tradeMemory || 0) * 0.04; +if (nomadism < 0.35) chance *= 0.18; +const tile = this.world.idx(agent.x, agent.y); +if (this.world.tradeRoute[tile]) chance += 0.04; +chance += clamp(Math.sqrt(Math.max(0, city.population || 0)) / 80, 0, 0.08); +chance += ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.015; +chance = clamp(chance, 0.005, 0.22); +if (this.rng.next() > chance) return; +if (agent.tradeOriginCityId === null) { +agent.tradeOriginCityId = city.id; +agent.lastTradeCityId = city.id; +agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.01, 0, 1); +agent.tradeCooldown = Math.floor(years(1)); +return; +} +if (agent.tradeOriginCityId === city.id) { +agent.lastTradeCityId = city.id; +agent.tradeCooldown = Math.floor(years(0.5)); +return; +} +const originCity = this.getCityById(agent.tradeOriginCityId); +if (!originCity) { +agent.tradeOriginCityId = city.id; +agent.lastTradeCityId = city.id; +agent.tradeCooldown = Math.floor(years(1)); +return; +} +this.completeAgentTrade(agent, originCity, city); +} +encounteredCityForAgent(agent) { +const w = this.world; +const tile = w.idx(agent.x, agent.y); +if (w.city[tile] >= 0) { +const city = this.getCityById(w.city[tile]); +if (city) return city; +} +const candidates = this.getCitiesNear(agent.x, agent.y, 4); +if (!candidates.length) return null; +candidates.sort((a, b) => { +const da = Math.abs(a.x - agent.x) + Math.abs(a.y - agent.y); +const db = Math.abs(b.x - agent.x) + Math.abs(b.y - agent.y); +if (da !== db) return da - db; +return (b.population || 0) - (a.population || 0); +}); +return candidates[0]; +} +completeAgentTrade(agent, originCity, destinationCity) { +if (!originCity || !destinationCity || originCity.id === destinationCity.id) return; +this.ensureAgentTech(agent); +this.ensureAgentTrade(agent); +const nomadism = this.agentNomadism(agent); +const distance = this.distanceBetweenCities(originCity, destinationCity); +const routeFactor = this.hasDirectTradeConnection(originCity, destinationCity) ? 1.35 : 1.0; +const distanceFactor = clamp(distance / 28, 0.45, 2.1); +const cityScale = Math.sqrt(Math.max(1, originCity.population || 1)) * +Math.sqrt(Math.max(1, destinationCity.population || 1)); +const techFactor = 1 + ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.12; +const memoryFactor = 1 + (agent.tradeMemory || 0) * 0.35; +const rawProfit = +cityScale * +0.010 * +distanceFactor * +routeFactor * +techFactor * +memoryFactor * +clamp(nomadism, 0.25, 1); +const profit = clamp(rawProfit, 0.25, 7.5); +agent.resources += profit * 0.50; +originCity.storedResources += profit * 0.20; +destinationCity.storedResources += profit * 0.30; +this.exchangeAgentTradeKnowledge(agent, originCity, destinationCity); +const currentTile = this.world.idx(agent.x, agent.y); +this.addPheromone(currentTile, profit * 0.45); +this.addPheromone(this.world.idx(originCity.x, originCity.y), profit * 0.30); +this.addPheromone(this.world.idx(destinationCity.x, destinationCity.y), profit * 0.35); +agent.tradeOriginCityId = destinationCity.id; +agent.lastTradeCityId = destinationCity.id; +agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.035, 0, 1); +agent.tradeCooldown = Math.floor(years(1.5)); +} +exchangeAgentTradeKnowledge(agent, originCity, destinationCity) { +if (originCity.knowledge) { +originCity.knowledge.farming = clamp(Math.max(originCity.knowledge.farming, (agent.tech?.farming || 0) * 0.45), 0, 1); +originCity.knowledge.metallurgy = clamp(Math.max(originCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.45), 0, 1); +} +if (destinationCity.knowledge) { +destinationCity.knowledge.farming = clamp(Math.max(destinationCity.knowledge.farming, (agent.tech?.farming || 0) * 0.55), 0, 1); +destinationCity.knowledge.metallurgy = clamp(Math.max(destinationCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.55), 0, 1); +agent.tech.farming = clamp(Math.max(agent.tech.farming || 0, destinationCity.knowledge.farming * 0.25), 0, 1); +agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy || 0, destinationCity.knowledge.metallurgy * 0.25), 0, 1); +} +} improveTechnologyFromDensity(agent) { let farmingCount = 0; let metallurgyCount = 0; @@ -1283,10 +1423,41 @@ return best; } updateWorldFields(scale = 1) { const w = this.world; -const pheromoneDecay = Math.pow(0.996, scale); +const pheromoneDecay = Math.pow(SimConfig.route.pheromoneDecay, scale); +const diffusion = SimConfig.route.pheromoneDiffusion * scale; +const nextPheromone = diffusion > 0 ? new Float32Array(w.pheromone) : null; for (let i = 0; i < w.count; i++) { w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15)); -w.pheromone[i] *= pheromoneDecay; +const decayed = w.pheromone[i] * pheromoneDecay; +if (nextPheromone) nextPheromone[i] = decayed; +else w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, decayed); +} +if (nextPheromone) { +for (let y = 0; y < w.size; y++) { +for (let x = 0; x < w.size; x++) { +const i = w.idx(x, y); +if (w.terrain[i] === Terrain.WATER) { +w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, nextPheromone[i] * 0.85); +continue; +} +let neighborSum = 0; +let neighborCount = 0; +for (let dy = -1; dy <= 1; dy++) { +for (let dx = -1; dx <= 1; dx++) { +if (!dx && !dy) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const tile = w.idx(tx, ty); +if (w.terrain[tile] === Terrain.WATER) continue; +neighborSum += nextPheromone[tile]; +neighborCount++; +} +} +const neighborAverage = neighborCount ? neighborSum / neighborCount : nextPheromone[i]; +w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, lerp(nextPheromone[i], neighborAverage, diffusion)); +} +} } } updateCities() { @@ -1426,18 +1597,21 @@ absorbUrbanPopulation() { if (!this.cities.length) return; const absorbed = []; for (const a of this.agents) { -if (!a.alive || a.settled < 5) { +if (!a.alive || a.settled < 3) { absorbed.push(a); continue; } -const city = this.findCityNear(a.x, a.y, 7); +const city = this.findCityNear(a.x, a.y, 9); const sedentary = getSedentary(a.traits); -if (!city || this.rng.next() > sedentary * 0.85) { +const nomadism = this.agentNomadism(a); +const nomadRetention = 1 - nomadism * 0.85; +const absorbChance = clamp((0.08 + sedentary * 1.05) * clamp(nomadRetention, 0.10, 1), 0.03, 0.92); +if (!city || this.rng.next() > absorbChance) { absorbed.push(a); continue; } -const migrants = 1 + Math.floor(Math.min(8, a.resources / 8)); -const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1); +const migrants = 1 + Math.floor(Math.min(10, a.resources / 7)); +const urbanWeight = clamp(0.12 + (sedentary - 0.10) * 1.55, 0.12, 1); city.population += migrants; city.storedResources += Math.max(0, a.resources) * 0.65; const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight); @@ -1473,7 +1647,7 @@ 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; +this.addPheromone(i, city.pheromoneOutput * pull * 0.09); harvested += extraction; } } @@ -1785,7 +1959,7 @@ for (const polity of this.polities) { polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5); polity.leaderStarted ??= polity.founded ?? this.year; polity.leaderTenureYears ??= this.rng.range(24, 68); -const tenureYears = (this.year - polity.leaderStarted) / MONTHS_PER_YEAR; +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); const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36); @@ -2093,7 +2267,7 @@ if (distance < best) best = distance; return best; } polityAge(polity) { -return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR; +return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR; } polityAgePressure(polity) { const age = this.polityAge(polity); @@ -2535,7 +2709,7 @@ triggerPolityCrises() { for (const polity of this.polities) { const age = this.polityAge(polity); if (age < 140) continue; -if ((this.year - (polity.lastCrisisYear ?? 0)) / MONTHS_PER_YEAR < 160) continue; +if ((this.year - (polity.lastCrisisYear ?? 0)) / WEEKS_PER_YEAR < 160) continue; const instability = this.polityInstability(polity); const agePressure = this.polityAgePressure(polity); const chance = 0.006 + agePressure * 0.018 + instability * 0.014; @@ -2757,7 +2931,6 @@ this.cleanupPolities(); if (this.samplePolityHistories) this.samplePolityHistories(); } reinforcePolityTradeRoutes() { -const w = this.world; for (const polity of this.polities) { if (this.rng.next() > 0.48) continue; const center = this.getCityById(polity.centerCityId); @@ -2770,6 +2943,7 @@ if (!candidates.length) continue; const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))]; const path = this.findTerrainRoute(center.x, center.y, target.x, target.y); if (!this.isValidRoutePath(path, target.x, target.y)) continue; +if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles || new Set())) continue; const cities = this.getPolityCities(polity); const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3; if ((polity.treasury || 0) < roadCost) { @@ -2778,13 +2952,7 @@ continue; } polity.treasury -= roadCost; if (!this.payRouteConstructionCost(center, target, path, polity)) continue; -center.tradeLinks.add(target.id); -target.tradeLinks.add(center.id); -this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path }); -for (const tile of path) { -w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34); -w.pheromone[tile] += 2.4; -} +this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id); } } updateTradeRoutes() { @@ -2792,7 +2960,12 @@ const w = this.world; for (let i = 0; i < w.count; i++) { const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2; if (invalidTerrain) w.tradeRoute[i] = 0; -else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2); +else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) { +const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); +const decay = pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 1 : SimConfig.route.routeUnsupportedDecay; +w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - decay); +if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; +} } this.tradeLinks = []; for (const city of this.cities) city.tradeLinks.clear(); @@ -2804,7 +2977,7 @@ for (let b = a + 1; b < this.cities.length; b++) { 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 > 44) continue; +if (d > SimConfig.route.maxRouteLength - 1) continue; const distanceFactor = clamp((d - 14) / 30, 0, 1); const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1); const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; @@ -2819,32 +2992,43 @@ const { c1, c2, distanceFactor, marketScale } = pair; 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); +const pheromoneSupport = this.routePheromoneSupport(path); +const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; if (strength < 0.08) continue; -if (!this.canPayRouteConstructionCost(c1, c2, path)) continue; -const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale; -candidates.push({ c1, c2, strength, path, tradeScore }); +if (ownerPolityId === null && pheromoneSupport < SimConfig.route.routePheromoneBuild) continue; +if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue; +const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale; +candidates.push({ c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }); } candidates.sort((a, b) => b.tradeScore - a.tradeScore); 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 { c1, c2, strength, path, pheromoneSupport, ownerPolityId } = candidate; 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); -c2.tradeLinks.add(c1.id); -this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path }); -this.exchangeCityResources(c1, c2, strength, path); -for (const tile of path) { -supportedRoutes.add(tile); -w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18); +if (this.hasReservedRouteSegment(path, supportedRoutes)) continue; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +if (!this.payRouteConstructionCost(c1, c2, path, ownerPolity)) continue; +const upkeepPaid = this.payRouteUpkeep(c1, c2, path, ownerPolity, pheromoneSupport); +if (!upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) { +this.weakenRoutePath(path, SimConfig.route.routeUnsupportedDecay); +continue; } +const routeBoost = upkeepPaid ? SimConfig.route.routeMaintainedBoost : Math.floor(SimConfig.route.routeMaintainedBoost * 0.45); +this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, 0, ownerPolityId); +this.exchangeCityResources(c1, c2, strength, path); +this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid); } 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); +if (w.tradeRoute[i] && !supportedRoutes.has(i)) { +const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); +w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - (pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 2 : SimConfig.route.routeUnsupportedDecay)); +if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; +} } this.activeTradeRouteTiles = new Set(); for (let i = 0; i < w.count; i++) { @@ -2852,6 +3036,16 @@ if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); } this.diffuseCityKnowledgeThroughTrade(); } +registerTradeLink(cityA, cityB, strength, path, supportedRoutes = null, routeBoost = 18, pheromoneBoost = 0, ownerPolityId = null) { +cityA.tradeLinks.add(cityB.id); +cityB.tradeLinks.add(cityA.id); +this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolityId }); +for (const tile of path) { +if (supportedRoutes) supportedRoutes.add(tile); +this.world.tradeRoute[tile] = Math.min(255, this.world.tradeRoute[tile] + routeBoost); +this.addPheromone(tile, pheromoneBoost); +} +} diffuseCityKnowledgeThroughTrade() { for (const link of this.tradeLinks) { const a = this.getCityById(link.from); @@ -2874,7 +3068,7 @@ const path = []; const visited = new Set(); let x = x1; let y = y1; -const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60); +const maxSteps = Math.min(SimConfig.route.maxRouteLength, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 24); const directions = [ [1, 0], [-1, 0], @@ -2925,12 +3119,28 @@ terrainEase += 1 / Math.max(1, w.move[i]); } return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2; } +hasReservedRouteSegment(path, reservedTiles) { +let previousReserved = false; +for (let p = 1; p < path.length - 1; p++) { +const reserved = reservedTiles.has(path[p]); +if (reserved && previousReserved) return true; +previousReserved = reserved; +} +return false; +} +routePheromoneSupport(path) { +const w = this.world; +if (!path.length) return 0; +let support = 0; +for (const i of path) support += clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); +return support / path.length; +} isValidRoutePath(path, targetX, targetY) { const w = this.world; if (!path.length) return false; const last = path[path.length - 1]; if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; -if (path.length > 42) return false; +if (path.length > SimConfig.route.maxRouteLength) return false; let totalCost = 0; let hardTiles = 0; const seen = new Set(); @@ -2950,6 +3160,52 @@ b.storedResources += delta; const traffic = Math.min(0.8, strength * 0.26); this.depositRoutePheromone(path, traffic); } +routeUpkeepCost(path, pheromoneSupport) { +const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55; +return path.length * SimConfig.route.routeUpkeepPerTile * (1 - pheromoneRelief); +} +payRouteUpkeep(cityA, cityB, path, polity = null, pheromoneSupport = 0) { +const cost = this.routeUpkeepCost(path, pheromoneSupport); +if (cost <= 0.001) return true; +let remaining = cost; +if (polity) { +const paid = Math.min(polity.treasury || 0, remaining * 0.72); +polity.treasury = Math.max(0, (polity.treasury || 0) - paid); +remaining -= paid; +} +const cityShare = remaining * 0.5; +remaining -= this.drainCityResources(cityA, cityShare); +remaining -= this.drainCityResources(cityB, cityShare); +if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); +if (remaining > 0 && polity) { +const paid = Math.min(polity.treasury || 0, remaining); +polity.treasury = Math.max(0, (polity.treasury || 0) - paid); +remaining -= paid; +} +return remaining <= cost * 0.25; +} +weakenRoutePath(path, amount) { +for (const tile of path) { +this.world.tradeRoute[tile] = Math.max(0, this.world.tradeRoute[tile] - amount); +if (this.world.tradeRoute[tile] < SimConfig.route.routeWeakThreshold) this.world.tradeRoute[tile] = 0; +} +} +applyRouteConnectionBenefits(cityA, cityB, strength, pheromoneSupport, upkeepPaid) { +const reliability = clamp(strength * 0.65 + pheromoneSupport * 0.35, 0, 1) * (upkeepPaid ? 1 : 0.55); +if (reliability <= 0) return; +const tradeValue = reliability * 0.18; +cityA.tradeValue = (cityA.tradeValue || 0) + tradeValue; +cityB.tradeValue = (cityB.tradeValue || 0) + tradeValue; +cityA.tradeReach = Math.max(cityA.tradeReach || 0, reliability); +cityB.tradeReach = Math.max(cityB.tradeReach || 0, reliability); +const resourceBonus = reliability * 0.12; +cityA.storedResources += resourceBonus; +cityB.storedResources += resourceBonus; +if (cityA.polityId !== null && cityA.polityId === cityB.polityId) { +cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1); +cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1); +} +} routeConstructionCost(path, polityBacked = false) { let weakTiles = 0; for (const tile of path) { @@ -2989,10 +3245,13 @@ const paid = Math.min(city.storedResources, amount); city.storedResources -= paid; return paid; } +addPheromone(tile, amount) { +if (tile < 0 || tile >= this.world.count || amount <= 0) return; +this.world.pheromone[tile] = Math.min(SimConfig.route.maxPheromone, this.world.pheromone[tile] + amount); +} depositRoutePheromone(path, amount) { -const w = this.world; for (const i of path) { -w.pheromone[i] += amount; +this.addPheromone(i, amount); } } updateEthnicStats() { @@ -3161,7 +3420,10 @@ if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressur 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); +for (const a of sim.agents) { +sim.ensureAgentTech(a); +sim.ensureAgentTrade(a); +} sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, { id: e.id, parent: e.parent, @@ -3315,6 +3577,10 @@ 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; +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); color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v); @@ -3329,6 +3595,9 @@ if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix( } } else if (mode === "technology") { color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35); +} else if (mode === "pheromone") { +const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); +color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v); } else { color = terrainInfo[w.terrain[i]].color; } @@ -3340,7 +3609,7 @@ data[p + 3] = 255; } ctx.putImageData(image, 0, 0); renderDisasters(); -drawTradeLinks(); +drawTradeLinks(mode); drawAgentsAndCities(mode); drawGraphPolityHighlight(); maybeRenderStateGraph(); @@ -3374,12 +3643,17 @@ ctx.stroke(); } ctx.restore(); } -function drawTradeLinks() { +function drawTradeLinks(mode) { const w = sim.world; ctx.save(); +const currentLinkTiles = new Set(); +for (const link of sim.tradeLinks) { +for (const tile of link.path || []) currentLinkTiles.add(tile); +} ctx.globalAlpha = 0.24; ctx.fillStyle = "#d9b650"; for (const i of sim.activeTradeRouteTiles || []) { +if (currentLinkTiles.has(i)) continue; ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1); } if (!sim.tradeLinks.length) { @@ -3397,6 +3671,7 @@ ctx.restore(); } function drawAgentsAndCities(mode) { ctx.save(); +if (mode !== "ethnicity") { for (const city of sim.cities) { const radius = cityRenderRadius(city); const color = cityDisplayColor(city, mode); @@ -3404,6 +3679,7 @@ ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`; ctx.globalAlpha = 0.88; ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); } +} ctx.globalAlpha = 1; if (mode === "technology") { for (const a of sim.agents) { @@ -3430,6 +3706,14 @@ ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); } } for (const city of sim.cities) { +if (mode === "ethnicity") { +const radius = cityRenderRadius(city); +const color = cityDisplayColor(city, mode); +ctx.globalAlpha = 0.90; +ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.78)`; +ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); +ctx.globalAlpha = 1; +} const color = cityDisplayColor(city, mode); ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`; ctx.fillRect(city.x, city.y, 1, 1); @@ -3535,10 +3819,11 @@ els.tooltip.innerHTML = ` city ? row("Trade", `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value`) : "", city ? row("Knowledge", `${(city.knowledge?.farming || 0).toFixed(2)} farm / ${(city.knowledge?.metallurgy || 0).toFixed(2)} metal`) : "", cityEthnicity ? row("City majority", `E${cityEthnicity}`) : "", + city ? cityEthnicityPie(city) : "", city ? row("State", polity ? `#${polity.id}` : "Independent") : "", city ? row("Loyalty", city.loyalty.toFixed(2)) : "", polity ? row("Charisma", clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)) : "", - polity ? row("Leader tenure", `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y`) : "", + polity ? row("Leader tenure", `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y`) : "", polity ? row("Treasury", polity.treasury.toFixed(1)) : "", polity ? row("Capital", polity.centerCityId === city.id ? "yes" : "no") : "" ])} @@ -3566,6 +3851,38 @@ if (top + height + margin > hoverState.height) top = hoverState.height - height 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 cityEthnicityPie(city) { +if (!city?.ethnicityComposition?.size) return ""; +const entries = [...city.ethnicityComposition] +.filter(([, count]) => count > 0) +.sort((a, b) => b[1] - a[1]); +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; +let cursor = 0; +const gradient = slices.map(([id, count]) => { +const start = cursor / total * 100; +cursor += count; +const end = cursor / total * 100; +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 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(""); +return ` +