diff --git a/index.html b/index.html index 96574af..269d45d 100644 --- a/index.html +++ b/index.html @@ -20,12 +20,6 @@ -
- - - -
- diff --git a/script.js b/script.js index 36d8474..d843674 100644 --- a/script.js +++ b/script.js @@ -16,14 +16,17 @@ 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 SAVE_KEY = "civil-emergence-save"; -const SAVE_VERSION = 3; -const MAX_SAVE_BYTES = 4_500_000; +const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH; const SimConfig = Object.freeze({ population: Object.freeze({ maxAgentsFloor: 8000, maxAgentsScale: 1.15, +softCapStart: 0.85, +hardCapScale: 1.35, +offspringCrowdingPenalty: 1.8, +minOffspringAcceptance: 0.035, maxOffspringPerStep: 360, carryingCapacityBase: 2.4, carryingCapacityFertility: 7.8, @@ -41,11 +44,6 @@ statsThrottleMs: 900, historyWindowYears: 2000, historySamplePaddingYears: 240 }), -save: Object.freeze({ -key: SAVE_KEY, -version: SAVE_VERSION, -maxBytes: MAX_SAVE_BYTES -}), technology: Object.freeze({ cityDiffusion: 0.0022, tradeDiffusion: 0.0032, @@ -55,17 +53,38 @@ polity: Object.freeze({ minimumPerCapitaFood: 0.06, logisticsDistance: 34 }), +polityAccess: Object.freeze({ +enabled: true, +maxSearchDepth: 8, +indirectPenalty: 0.012, +perHopPenalty: 0.006, +noAccessPenalty: 0.045, +lowLoyaltyTransitPenalty: 0.012, +foreignTransitPenalty: 0.008 +}), culture: Object.freeze({ spreadRadius: 3, 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, @@ -76,53 +95,36 @@ maxIntensity: 0.95, visualDurationYears: 18, maxActiveVisuals: 12, maxHistory: 160 +}), +frontierWave: Object.freeze({ +enabledAfterYears: 300, +intervalYears: 80, +chance: 0.45, +minAgents: 60, +maxAgents: 140, +maxPopulationRatio: 1.28, +edgeBand: 4, +mutation: 0.08, +minResources: 12, +maxResources: 28 }) }); function years(value) { -return value * MONTHS_PER_YEAR; +return value * WEEKS_PER_YEAR; } -const els = { -canvas: document.getElementById("world"), -sim: document.querySelector(".sim"), -toggleRun: document.getElementById("toggleRun"), -stepOnce: document.getElementById("stepOnce"), -resetWorld: document.getElementById("resetWorld"), -saveWorld: document.getElementById("saveWorld"), -loadWorld: document.getElementById("loadWorld"), -clearSave: document.getElementById("clearSave"), -speed: document.getElementById("speed"), -agentCount: document.getElementById("agentCount"), -worldSize: document.getElementById("worldSize"), -viewMode: document.getElementById("viewMode"), -year: document.getElementById("year"), -activeGroups: document.getElementById("activeGroups"), -urbanPopulation: document.getElementById("urbanPopulation"), -ethnicities: document.getElementById("ethnicities"), -cities: document.getElementById("cities"), -polities: document.getElementById("polities"), -wars: document.getElementById("wars"), -routes: document.getElementById("routes"), -farmingKnowledge: document.getElementById("farmingKnowledge"), -metallurgyKnowledge: document.getElementById("metallurgyKnowledge"), -deaths: document.getElementById("deaths"), -frameCost: document.getElementById("frameCost"), -ethnicityList: document.getElementById("ethnicityList"), -legend: document.getElementById("legend"), -tooltip: document.getElementById("tooltip"), -stateGraph: document.getElementById("stateGraph"), -historyRange: document.getElementById("historyRange"), -historyFilter: document.getElementById("historyFilter"), -historySort: document.getElementById("historySort"), -historyMetric: document.getElementById("historyMetric"), -toggleActiveStates: document.getElementById("toggleActiveStates"), -togglePastStates: document.getElementById("togglePastStates"), -stateGraphInfo: document.getElementById("stateGraphInfo"), -stateGraphTooltip: document.getElementById("stateGraphTooltip") -}; +const elementIds = [ +"world", "toggleRun", "stepOnce", "resetWorld", +"speed", "agentCount", "worldSize", "viewMode", "year", "activeGroups", "urbanPopulation", +"ethnicities", "cities", "polities", "wars", "routes", "farmingKnowledge", "metallurgyKnowledge", +"deaths", "frameCost", "ethnicityList", "legend", "tooltip", "stateGraph", "historyRange", +"historyFilter", "historySort", "historyMetric", "toggleActiveStates", "togglePastStates", +"stateGraphInfo", "stateGraphTooltip" +]; +const els = Object.fromEntries(elementIds.map(id => [id === "world" ? "canvas" : id, document.getElementById(id)])); +els.sim = document.querySelector(".sim"); const ctx = els.canvas.getContext("2d", { alpha: false }); let sim; let running = true; -let frame = 0; let lastStatsAt = 0; let lastGraphRenderAt = 0; let lastRenderAt = 0; @@ -151,6 +153,15 @@ rows: [], markers: [], scale: null }; +const legendByMode = { +terrain: () => terrainInfo.map(t => `${t.name}`).join(""), +ethnicity: () => "Agent and city colors show lineage. Land remains terrain-colored.", +pressure: () => "High local population pressure", +polities: () => "Color = city-centered state. Uncolored cities are independent.", +technology: () => "Farming knowledgeMetallurgy knowledge", +pheromone: () => "Pheromone strengthFormal trade routes", +resources: () => "Regenerating local resource stock" +}; class Rng { constructor(seed) { this.seed = seed >>> 0; @@ -167,7 +178,7 @@ return Math.floor(this.next() * max); } } class World { -constructor(size, rng, generate = true) { +constructor(size, rng) { this.size = size; this.count = size * size; this.rng = rng; @@ -189,10 +200,7 @@ this.dominantEthnicity = new Int32Array(this.count); this.cultureDiversity = new Float32Array(this.count); this.city.fill(-1); this.dominantEthnicity.fill(-1); -if (generate) this.generate(); -} -static blank(size, rng) { -return new World(size, rng, false); +this.generate(); } idx(x, y) { return y * this.size + x; @@ -443,9 +451,9 @@ return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))]; } } class Simulation { -constructor(size, initialAgents, options = {}) { +constructor(size, initialAgents) { this.rng = new Rng(Date.now()); -this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng); +this.world = new World(size, this.rng); this.agents = []; this.ethnicities = new Map(); this.cities = []; @@ -456,6 +464,7 @@ this.polityById = new Map(); this.wars = []; this.disasters = []; this.disasterHistory = []; +this.graphEvents = []; this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; @@ -475,12 +484,10 @@ SimConfig.population.maxAgentsFloor ); this.tileEthnicities = new Map(); this.tileAgents = new Map(); -if (!options.skipSpawn) { this.spawnInitialAgents(initialAgents); this.rebuildOccupancy(); this.updateEthnicStats(); } -} spawnInitialAgents(count) { const founders = Math.max(5, Math.min(16, Math.round(count / 180))); const desertFounders = Math.max(2, Math.floor(founders * 0.25)); @@ -556,7 +563,11 @@ farming: 0, metallurgy: 0 }, farmingWork: 0, -lastFarmTile: -1 +lastFarmTile: -1, +tradeOriginCityId: null, +lastTradeCityId: null, +tradeMemory: 0, +tradeCooldown: 0 }; } findNearbySpawn(originX, originY, preferredTerrain = null) { @@ -610,6 +621,74 @@ return { x, y }; } return { x: this.world.size >> 1, y: this.world.size >> 1 }; } +frontierSpawnTile(side, edgeBand = 4) { +const w = this.world; +const band = Math.max(1, Math.floor(edgeBand)); +let best = null; +let bestScore = -Infinity; +for (let tries = 0; tries < 160; tries++) { +let x = this.rng.int(w.size); +let y = this.rng.int(w.size); +if (side === 0) y = this.rng.int(band); +else if (side === 1) y = w.size - 1 - this.rng.int(band); +else if (side === 2) x = this.rng.int(band); +else x = w.size - 1 - this.rng.int(band); +const i = w.idx(x, y); +if (w.terrain[i] === Terrain.WATER) continue; +const score = w.resource[i] * 0.04 + w.fertility[i] * 0.85 + w.mineral[i] * 0.25 - w.move[i] * 0.12; +if (score > bestScore) { +best = { x, y }; +bestScore = score; +} +} +return best || this.findHabitableTile(); +} +maybeSpawnFrontierWave() { +const cfg = SimConfig.frontierWave; +if (!cfg) return; +if (this.year < years(cfg.enabledAfterYears ?? 300)) return; +const interval = years(cfg.intervalYears ?? 80); +if (!interval || this.year % interval !== 0) return; +if (this.rng.next() > (cfg.chance ?? 0.45)) return; +const populationLimit = this.maxAgents * (cfg.maxPopulationRatio ?? 1.28); +if (this.agents.length >= populationLimit) return; +const side = this.rng.int(4); +const targetCount = this.rng.int(Math.max(1, (cfg.maxAgents ?? 140) - (cfg.minAgents ?? 60) + 1)) + (cfg.minAgents ?? 60); +const origin = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); +const originTile = this.world.idx(origin.x, origin.y); +const ethnicity = this.createEthnicity(0, { +temperature: this.world.temperature[originTile], +humidity: this.world.humidity[originTile] +}); +const baseTraits = this.randomTraits(); +baseTraits.mobility = this.rng.range(0.65, 1.0); +baseTraits.sedentary = this.rng.range(0.02, 0.35); +baseTraits.ethnocentrism = this.rng.range(0.42, 0.95); +baseTraits.assimilation = this.rng.range(0.02, 0.22); +baseTraits.resourceAttraction = this.rng.range(0.78, 1.15); +let spawned = 0; +for (let n = 0; n < targetCount && this.agents.length < populationLimit; n++) { +const spawn = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); +const tile = this.world.idx(spawn.x, spawn.y); +if (this.world.terrain[tile] === Terrain.WATER) continue; +const agent = this.makeAgent( +spawn.x, +spawn.y, +ethnicity, +mutateTraits(baseTraits, this.rng, cfg.mutation ?? 0.08), +this.rng.range(cfg.minResources ?? 12, cfg.maxResources ?? 28) +); +agent.tradeMemory = this.rng.range(0.05, 0.22); +this.agents.push(agent); +if (this.addAgentToOccupancy) this.addAgentToOccupancy(agent); +spawned++; +} +if (spawned <= 0) this.ethnicities.delete(ethnicity); +else this.addGraphEvent("newEthnicity", this.year, { +ethnicity, +population: spawned +}); +} step() { for (const city of this.cities) city.activeVisitors = 0; this.rebuildOccupancy(); @@ -624,18 +703,31 @@ 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); } this.agents = this.agents.filter(a => a.alive); -const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents -? offspring -: offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)); +const acceptedOffspring = []; +const softCap = Math.max(1, this.maxAgents); +const hardCap = Math.max(softCap, Math.floor(softCap * (SimConfig.population.hardCapScale ?? 1.35))); +const softCapStart = SimConfig.population.softCapStart ?? 0.85; +const crowdingPenalty = SimConfig.population.offspringCrowdingPenalty ?? 1.8; +const minAcceptance = SimConfig.population.minOffspringAcceptance ?? 0.035; +for (const child of offspring) { +const projectedPopulation = this.agents.length + acceptedOffspring.length; +if (projectedPopulation >= hardCap) break; +const pressure = projectedPopulation / softCap; +const overSoftCap = Math.max(0, pressure - softCapStart); +const acceptChance = clamp(1 - overSoftCap * crowdingPenalty, minAcceptance, 1); +if (pressure <= softCapStart || this.rng.next() < acceptChance) acceptedOffspring.push(child); +} this.agents.push(...acceptedOffspring); for (const child of acceptedOffspring) this.addAgentToOccupancy(child); 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.updateCities(); if (this.year % years(2) === 0) this.updateRegionalCultures(); @@ -839,8 +931,8 @@ const localPressure = w.pressure[current]; let bestX = a.x; let bestY = a.y; let bestScore = -Infinity; -const sedentary = getSedentary(a.traits); -const ethnocentrism = getEthnocentrism(a.traits); +const sedentary = a.traits.sedentary; +const ethnocentrism = a.traits.ethnocentrism; const ethnicClimate = this.ethnicities.get(a.ethnicity); const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45; const localCapacity = this.carryingCapacityAt(current); @@ -864,13 +956,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 +998,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 +1016,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?.sedentary ?? 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; @@ -977,7 +1083,7 @@ const settledFactor = clamp((agent.settled || 0) / 12, 0, 1); if ((agent.tech?.farming || 0) <= 0.02) { const farmingBase = 0.00016; const farmability = this.farmabilityAt(tile); -const sedentary = getSedentary(agent.traits); +const sedentary = agent.traits.sedentary; const farmingChance = farmingBase * (0.25 + farmability) * @@ -1037,6 +1143,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 +1158,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 +1183,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; @@ -1159,7 +1372,7 @@ const waterAdaptation = ethnicity?.climateHumidity ?? 0.45; const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0; const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4; const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1; -const sedentary = getSedentary(a.traits); +const sedentary = a.traits.sedentary; const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08; a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead; if (a.resources <= 0) { @@ -1235,8 +1448,8 @@ a.foreignContact = 0; } a.foreignContact++; const pressure = dominant.count / Math.max(1, dominant.total); -const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30); -if (this.rng.next() < chance * 0.14) { +const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 100); +if (this.rng.next() < chance * 0.0001) { this.changeAgentEthnicity(a, dominant.id); a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22); a.foreignContact = 0; @@ -1268,7 +1481,7 @@ const distance = Math.abs(city.x - x) + Math.abs(city.y - y); if (!city.ethnicityComposition?.size) continue; const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8); for (const [id, count] of city.ethnicityComposition) { -const weighted = Math.max(1, Math.round(count * influence * 0.08)); +const weighted = Math.max(1, Math.round(count * influence * 0.015)); total += weighted; counts.set(id, (counts.get(id) || 0) + weighted); } @@ -1283,10 +1496,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() { @@ -1311,7 +1555,7 @@ candidates.set(i, group); this.ensureAgentTech(a); group.count++; group.resources += Math.max(0, a.resources); -group.sedentary += getSedentary(a.traits); +group.sedentary += a.traits.sedentary; group.farming += a.tech.farming || 0; group.metallurgy += a.tech.metallurgy || 0; group.techCount++; @@ -1426,18 +1670,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 sedentary = getSedentary(a.traits); -if (!city || this.rng.next() > sedentary * 0.85) { +const city = this.findCityNear(a.x, a.y, 9); +const sedentary = a.traits.sedentary; +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 +1720,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; } } @@ -1504,7 +1751,7 @@ const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0 const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015))); city.population += births; city.storedResources -= births * 0.55; -addBirthsToComposition(city.ethnicityComposition, births); +addBirthsToComposition(city.ethnicityComposition, births, this.rng); } if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) { const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010))); @@ -1638,7 +1885,70 @@ distanceBetweenCities(cityA, cityB) { return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y); } hasDirectTradeConnection(cityA, cityB) { -return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id); +if (!cityA || !cityB) return false; +if (cityA.tradeLinks?.has(cityB.id) || cityB.tradeLinks?.has(cityA.id)) return true; +return (this.tradeLinks || []).some(link => { +const endpoints = this.tradeLinkEndpointIds(link); +return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id); +}); +} +tradeLinkEndpointId(value) { +return typeof value === "object" ? value?.id ?? null : value ?? null; +} +tradeLinkEndpointIds(link) { +const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityAId ?? link.aId ?? link.sourceId ?? link.source ?? link.a ?? 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; +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; +} +} +return clamp(penalty, 0, 0.07); } effectiveDistance(cityA, cityB) { let distance = this.distanceBetweenCities(cityA, cityB); @@ -1725,15 +2035,6 @@ history.samples.push(sample); if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) { history.peakPower = sample.power; history.peakYear = this.year; -const oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40); -if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) { -this.addPolityEvent(polity.id, "peak", this.year, { -power: sample.power, -cities: sample.cities, -population: sample.population -}, 2); -history.peakEventYear = this.year; -} } const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; while (history.samples.length > maxSamples) history.samples.shift(); @@ -1758,6 +2059,17 @@ if (sameYearDuplicate) return; history.events.push({ year, type, importance, data }); while (history.events.length > 120) history.events.shift(); } +addGraphEvent(type, year = this.year, data = {}, importance = 2) { +this.graphEvents ??= []; +const duplicate = this.graphEvents.some(event => +event.type === type && +event.year === year && +JSON.stringify(event.data || {}) === JSON.stringify(data || {}) +); +if (duplicate) return; +this.graphEvents.push({ year, type, importance, data }); +while (this.graphEvents.length > 160) this.graphEvents.shift(); +} selectNewLeader(polity, forced = false) { if (!polity) return; const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5); @@ -1785,7 +2097,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); @@ -1979,6 +2291,12 @@ city.receivedAid = false; isPolityAtWar(polityId) { return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)); } +warCountForPolity(polityId) { +return this.wars.filter(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)).length; +} +canBeWarTarget(polityId) { +return this.warCountForPolity(polityId) < 3; +} getWarBetween(aId, bId) { return this.wars.find(war => war.ended === null && @@ -1988,7 +2306,7 @@ war.ended === null && startWar(a, b) { if (!a || !b || a.id === b.id) return null; if (this.getWarBetween(a.id, b.id)) return null; -if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null; +if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null; const id = this.nextWar++; const war = { id, @@ -2093,7 +2411,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); @@ -2263,7 +2581,7 @@ absorbed++; } } maybeStartWars() { -if (this.year % years(10) !== 0) return; +if (this.year % years(1) !== 0) return; for (const a of this.polities) { if (this.isPolityAtWar(a.id)) continue; const aPower = this.polityPower(a); @@ -2271,8 +2589,8 @@ if (aPower <= 0) continue; let started = false; for (const b of this.polities) { if (started) break; -if (a.id >= b.id) continue; -if (this.isPolityAtWar(b.id)) continue; +if (a.id === b.id) continue; +if (!this.canBeWarTarget(b.id)) continue; if (this.getWarBetween(a.id, b.id)) continue; const distance = this.polityDistance(a, b); if (distance > 46) continue; @@ -2288,22 +2606,41 @@ const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0; const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0; const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0; const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; +const aLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(a) : 0.5; +const bLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5; const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1); const weakSideInstability = aPower > bPower ? bInstability : aInstability; const weakSideAge = aPower > bPower ? bAge : aAge; const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension; -const generalInstability = Math.max(aInstability, bInstability) * 0.04; +const weakSideLoyalty = aPower > bPower ? bLoyalty : aLoyalty; +const lowLoyaltyPressure = clamp(1 - Math.min(aLoyalty, bLoyalty), 0, 1); +const weakSideLowLoyalty = clamp(1 - weakSideLoyalty, 0, 1); +const generalInstability = Math.max(aInstability, bInstability) * 0.035; +const targetWarCount = this.warCountForPolity(b.id); +const targetLowLoyalty = clamp(1 - bLoyalty, 0, 1); +const attackerAdvantage = clamp((aPower / Math.max(1, bPower) - 1) / 2.25, 0, 1); +const opportunisticPressure = targetWarCount > 0 +? clamp(targetWarCount / 3, 0, 1) * ( +0.010 + +bInstability * 0.018 + +targetLowLoyalty * 0.018 + +attackerAdvantage * 0.020 +) +: 0; const borderFriction = this.borderFriction(a, b, distance); const chance = -0.002 + -proximity * 0.010 + -asymmetryPressure * 0.010 + -weakSideInstability * 0.020 + -weakSideAge * 0.012 + -weakSideOverextension * 0.012 + -borderFriction * 0.010 + -generalInstability; -if (this.rng.next() < clamp(chance, 0, 0.08)) { +0.001 + +proximity * 0.007 + +asymmetryPressure * 0.008 + +weakSideInstability * 0.018 + +weakSideAge * 0.010 + +weakSideOverextension * 0.010 + +borderFriction * 0.008 + +generalInstability + +lowLoyaltyPressure * 0.018 + +weakSideLowLoyalty * 0.028 + +opportunisticPressure; +if (this.rng.next() < clamp(chance, 0, 0.12)) { this.startWar(a, b); started = true; } @@ -2535,7 +2872,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; @@ -2596,7 +2933,9 @@ let delta = 0; 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); -if (this.hasDirectTradeConnection(city, center)) delta += 0.025; +const directCenterTrade = this.hasDirectTradeConnection(city, center); +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; @@ -2622,7 +2961,15 @@ const chance = (threshold - city.loyalty) * 0.28 + instability * 0.012 + agePressure * 0.010; -if (this.rng.next() < chance) this.removeCityFromPolity(city); +if (this.rng.next() < chance) { +const rebellionLoyalty = city.loyalty ?? 0; +this.removeCityFromPolity(city); +this.addPolityEvent(polity.id, "rebellion", this.year, { +cityId: city.id, +population: city.population || 0, +loyalty: rebellionLoyalty +}, 3); +} } } } @@ -2757,7 +3104,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 +3116,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 +3125,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 +3133,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 +3150,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 +3165,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 +3209,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 +3241,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 +3292,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 +3333,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 +3418,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() { @@ -3064,225 +3496,6 @@ for (const a of candidates) a.ethnicity = newId; } } } -toJSON() { -return { -version: SimConfig.save.version, -rngSeed: this.rng.seed, -year: this.year, -deaths: this.deaths, -nextEthnicity: this.nextEthnicity, -nextCity: this.nextCity, -nextPolity: this.nextPolity, -nextWar: this.nextWar, -nextDisaster: this.nextDisaster, -maxAgents: this.maxAgents, -world: { -size: this.world.size, -terrain: packArray(this.world.terrain), -resource: packArray(this.world.resource), -regen: packArray(this.world.regen), -move: packArray(this.world.move), -fertility: packArray(this.world.fertility), -mineral: packArray(this.world.mineral), -temperature: packArray(this.world.temperature), -humidity: packArray(this.world.humidity), -pheromone: packArray(this.world.pheromone), -tradeRoute: packArray(this.world.tradeRoute), -farmland: packArray(this.world.farmland), -cityPull: packArray(this.world.cityPull), -city: packArray(this.world.city), -pressure: packArray(this.world.pressure), -dominantEthnicity: packArray(this.world.dominantEthnicity), -cultureDiversity: packArray(this.world.cultureDiversity) -}, -agents: this.agents, -ethnicities: [...this.ethnicities.values()].map(e => ({ -id: e.id, -parent: e.parent, -born: e.born, -climateTemp: e.climateTemp, -climateHumidity: e.climateHumidity, -color: e.color -})), -cities: this.cities.map(c => ({ -...c, -ethnicityComposition: [...c.ethnicityComposition], -tradeLinks: [...c.tradeLinks] -})), -polities: this.polities.map(p => ({ -id: p.id, -centerCityId: p.centerCityId, -cityIds: [...p.cityIds], -treasury: p.treasury, -color: p.color, -founded: p.founded, -legitimacy: p.legitimacy, -cohesion: p.cohesion, -charisma: p.charisma, -leaderStarted: p.leaderStarted, -leaderTenureYears: p.leaderTenureYears, -crisis: p.crisis, -lastCrisisYear: p.lastCrisisYear, -lastFamineYear: p.lastFamineYear ?? null -})), -wars: this.wars, -disasters: this.disasters, -disasterHistory: this.disasterHistory, -polityHistory: [...this.polityHistory.values()], -deadPolityHistories: this.deadPolityHistories -}; -} -static fromJSON(state) { -if (!state?.world?.size) throw new Error("Invalid save payload"); -const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true }); -sim.rng.seed = state.rngSeed >>> 0; -sim.year = state.year || 0; -sim.deaths = state.deaths || 0; -sim.nextEthnicity = state.nextEthnicity || 1; -sim.nextCity = state.nextCity || 1; -sim.nextPolity = state.nextPolity || 1; -sim.nextWar = state.nextWar || 1; -sim.nextDisaster = state.nextDisaster || 1; -sim.maxAgents = state.maxAgents || 30000; -sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); -sim.world.resource.set(unpackArray(state.world.resource, Float32Array)); -sim.world.regen.set(unpackArray(state.world.regen, Float32Array)); -sim.world.move.set(unpackArray(state.world.move, Float32Array)); -sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array)); -sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array)); -sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array)); -sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array)); -sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array)); -sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array)); -sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array)); -sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array)); -sim.world.city.set(unpackArray(state.world.city, Int32Array)); -if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array)); -if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array)); -if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array)); -sim.agents = state.agents || []; -for (const a of sim.agents) sim.ensureAgentTech(a); -sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, { -id: e.id, -parent: e.parent, -born: e.born, -population: 0, -diversity: 0, -climateTemp: e.climateTemp, -climateHumidity: e.climateHumidity, -color: e.color, -centroidX: 0, -centroidY: 0 -}])); -sim.cities = (state.cities || []).map(c => ({ -id: c.id, -x: c.x, -y: c.y, -population: c.population || 0, -storedResources: c.storedResources || 0, -ethnicityComposition: new Map(c.ethnicityComposition || []), -pheromoneOutput: c.pheromoneOutput || 0, -agriculturalRadius: c.agriculturalRadius || 2, -tradeLinks: new Set(c.tradeLinks || []), -activeVisitors: 0, -age: c.age || 0, -strength: c.strength || 1, -sedentaryCulture: c.sedentaryCulture ?? 0.5, -knowledge: { -farming: clamp(c.knowledge?.farming ?? 0, 0, 1), -metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1) -}, -supplyStress: c.supplyStress ?? 0, -polityId: c.polityId ?? null, -loyalty: c.loyalty ?? 0.5, -receivedAid: c.receivedAid ?? false, -tradeValue: c.tradeValue ?? 0, -tradeReach: c.tradeReach ?? 0 -})); -sim.polities = (state.polities || []).map(p => ({ -id: p.id, -centerCityId: p.centerCityId, -cityIds: new Set(p.cityIds || []), -treasury: p.treasury || 0, -color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62), -founded: p.founded || 0, -legitimacy: p.legitimacy ?? 0.7, -cohesion: p.cohesion ?? 0.6, -charisma: clamp(p.charisma ?? 1, 0.5, 1.5), -leaderStarted: p.leaderStarted ?? p.founded ?? sim.year, -leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96), -crisis: p.crisis ?? 0, -lastCrisisYear: p.lastCrisisYear ?? sim.year, -lastFamineYear: p.lastFamineYear ?? null -})); -sim.rebuildIndexes(); -sim.wars = (state.wars || []).map(w => ({ -id: w.id, -aPolityId: w.aPolityId, -bPolityId: w.bPolityId, -started: w.started || sim.year, -lastActionYear: w.lastActionYear || w.started || sim.year, -intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25), -exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1), -exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1), -ended: w.ended ?? null -})).filter(w => w.ended === null); -const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0); -sim.nextWar = Math.max(sim.nextWar, maxWarId + 1); -const restoreDisaster = d => ({ -id: d.id, -year: d.year || 0, -x: d.x || 0, -y: d.y || 0, -radius: d.radius || 8, -intensity: d.intensity || 0.5, -expires: d.expires ?? ((d.year || 0) + years(18)), -affectedAgents: d.affectedAgents || 0, -affectedCities: d.affectedCities || 0, -totalPopulationLoss: d.totalPopulationLoss || 0, -affectedPolities: d.affectedPolities || [] -}); -sim.disasters = (state.disasters || []).map(restoreDisaster); -sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster); -const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0); -sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1); -sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, { -id: h.id, -color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), -founded: h.founded || 0, -ended: h.ended ?? null, -active: h.active !== false, -centerCityId: h.centerCityId ?? null, -fate: h.fate || null, -samples: h.samples || [], -events: h.events || [], -peakPower: h.peakPower || 0, -peakYear: h.peakYear ?? null, -peakEventYear: h.peakEventYear ?? null -}])); -sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({ -id: h.id, -color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), -founded: h.founded || 0, -ended: h.ended ?? null, -active: false, -centerCityId: h.centerCityId ?? null, -fate: h.fate || null, -samples: h.samples || [], -events: h.events || [], -peakPower: h.peakPower || 0, -peakYear: h.peakYear ?? null, -peakEventYear: h.peakEventYear ?? null -})); -for (const polity of sim.polities) sim.ensurePolityHistory(polity); -sim.tradeLinks = []; -sim.activeTradeRouteTiles = new Set(); -sim.rebuildOccupancy(); -sim.updateTradeRoutes(); -sim.cleanupPolities(); -sim.updateEthnicStats(); -return sim; -} } function render() { const start = performance.now(); @@ -3299,7 +3512,7 @@ const cityPolityColor = new Map(); if (mode === "polities") { for (const city of sim.cities) { if (city.polityId === null) continue; -const polity = sim.getPolityById(city.polityId); +const polity = cityPolity(city); if (!polity) continue; const isGraphHover = graphState.hoverPolityId === polity.id; cityPolityColor.set(city.id, { @@ -3315,6 +3528,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 +3546,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; } @@ -3363,23 +3583,44 @@ gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`); gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`); gradient.addColorStop(1, "rgba(211, 95, 84, 0)"); ctx.fillStyle = gradient; -ctx.beginPath(); -ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); -ctx.fill(); +drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius); ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`; ctx.lineWidth = 1; -ctx.beginPath(); -ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); -ctx.stroke(); +strokeCircle(ctx, disaster.x, disaster.y, disaster.radius); } ctx.restore(); } +function rgb(color, boost = 0) { +return `rgb(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)})`; +} +function rgba(color, alpha, boost = 0) { +return `rgba(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)}, ${alpha})`; +} +function fillSquare(g, x, y, radius) { +g.fillRect(x - radius, y - radius, radius * 2 + 1, radius * 2 + 1); +} +function strokeLine(g, x1, y1, x2, y2) { +g.beginPath(); +g.moveTo(x1, y1); +g.lineTo(x2, y2); +g.stroke(); +} +function strokeCircle(g, x, y, r) { +g.beginPath(); +g.arc(x, y, r, 0, Math.PI * 2); +g.stroke(); +} function drawTradeLinks() { 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,30 +3638,32 @@ 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); -ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`; +ctx.fillStyle = rgba(color, 0.72); ctx.globalAlpha = 0.88; -ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); +fillSquare(ctx, city.x, city.y, radius); +} } ctx.globalAlpha = 1; if (mode === "technology") { for (const a of sim.agents) { sim.ensureAgentTech(a); -const farming = a.tech.farming || 0; -const metallurgy = a.tech.metallurgy || 0; +const farming = techLevel(a, "farming"); +const metallurgy = techLevel(a, "metallurgy"); const tech = clamp(Math.max(farming, metallurgy), 0, 1); if (tech <= 0.01) continue; const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy)); -ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`; +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[0]},${e.color[1]},${e.color[2]})`; +ctx.fillStyle = rgb(e.color); ctx.fillRect(a.x, a.y, 1, 1); } } else { @@ -3430,8 +3673,16 @@ 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.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`; +ctx.globalAlpha = 0.90; +ctx.fillStyle = rgba(color, 0.78); +fillSquare(ctx, city.x, city.y, radius); +ctx.globalAlpha = 1; +} +const color = cityDisplayColor(city, mode); +ctx.fillStyle = rgb(color, 55); ctx.fillRect(city.x, city.y, 1, 1); } ctx.restore(); @@ -3449,10 +3700,10 @@ for (const city of cities) { const radius = Math.max(3, cityRenderRadius(city) + 2); const color = polity.color || [238, 232, 188]; ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72; -ctx.strokeStyle = `rgb(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`; +ctx.strokeStyle = rgb(color, 72); ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); ctx.globalAlpha = 0.32; -ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; +ctx.fillStyle = rgb(color); ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1)); } ctx.restore(); @@ -3464,16 +3715,23 @@ function cityMajorityColor(city) { const id = dominantComposition(city.ethnicityComposition); return sim.ethnicities.get(id)?.color || [242, 215, 134]; } +function cityPolity(city) { +return city?.polityId != null ? sim.getPolityById(city.polityId) : null; +} function cityDisplayColor(city, mode) { if (mode === "polities") { -if (city.polityId !== null) { -const polity = sim.getPolityById(city.polityId); +const polity = cityPolity(city); if (polity) return polity.color; -} return [218, 205, 154]; } return cityMajorityColor(city); } +function techLevel(holder, key) { +return holder?.tech?.[key] || 0; +} +function knowledgeLevel(city, key) { +return city?.knowledge?.[key] || 0; +} function showTooltip(event) { const rect = els.canvas.getBoundingClientRect(); const simRect = els.sim.getBoundingClientRect(); @@ -3489,6 +3747,13 @@ height: simRect.height }; renderTooltip(); } +function tooltipRow(label, value, show = true) { +return show ? `${label}${value}` : ""; +} +function tooltipSection(title, items) { +const body = items.filter(Boolean).join(""); +return body ? `

${title}

${body}
` : ""; +} function renderTooltip() { if (!hoverState) return; const x = hoverState.x; @@ -3506,53 +3771,48 @@ const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null; 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 = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null; +const polity = cityPolity(city); const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; -const rows = items => items.filter(Boolean).join(""); -const row = (label, value) => `${label}${value}`; -const section = (title, items) => { -const body = rows(items); -return body ? `

${title}

${body}
` : ""; -}; els.tooltip.innerHTML = ` ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} - ${section("Tile", [ - row("Position", `${x}, ${y}`), - row("Terrain", terrain.name), - row("Resources", w.resource[i].toFixed(1)), - row("Fertility", w.fertility[i].toFixed(2)), - row("Minerals", w.mineral[i].toFixed(2)), - row("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`), - row("Water", waterInfluence), - row("Pressure", w.pressure[i].toFixed(0)), - row("Pheromone", w.pheromone[i].toFixed(1)), - w.tradeRoute[i] ? row("Route", w.tradeRoute[i]) : "" + ${tooltipSection("Tile", [ + tooltipRow("Position", `${x}, ${y}`), + tooltipRow("Terrain", terrain.name), + tooltipRow("Resources", w.resource[i].toFixed(1)), + tooltipRow("Fertility", w.fertility[i].toFixed(2)), + 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("Pheromone", w.pheromone[i].toFixed(1)), + tooltipRow("Route", w.tradeRoute[i], w.tradeRoute[i]) ])} - ${section("City & State", [ - city ? row("Population", city.population.toLocaleString()) : "", - city ? row("Food stock", city.storedResources.toFixed(1)) : "", - city ? row("Supply stress", (city.supplyStress || 0).toFixed(2)) : "", - 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 ? 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("Treasury", polity.treasury.toFixed(1)) : "", - polity ? row("Capital", polity.centerCityId === city.id ? "yes" : "no") : "" + ${tooltipSection("City & State", [ + tooltipRow("Population", city?.population.toLocaleString(), 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", `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, 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) ])} - ${section("Culture & Agent", [ - row("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`), - agent ? row("Agent ethnicity", `E${agent.ethnicity}`) : "", - ethnicity ? row("Lineage pop", ethnicity.population) : "", - ethnicity ? row("Climate pref", `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}`) : "", - agent ? row("Climate mismatch", mismatch.toFixed(2)) : "", - agent ? row("Sedentary", getSedentary(agent.traits).toFixed(2)) : "", - agent ? row("Ethnocentrism", getEthnocentrism(agent.traits).toFixed(2)) : "", - agent ? row("Farming", `${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}`) : "", - agent ? row("Metallurgy", (agent.tech?.metallurgy || 0).toFixed(3)) : "", - agent ? row("Stored", agent.resources.toFixed(1)) : "" + ${tooltipSection("Culture & Agent", [ + tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`), + tooltipRow("Agent ethnicity", `E${agent?.ethnicity}`, agent), + tooltipRow("Lineage pop", ethnicity?.population, ethnicity), + tooltipRow("Climate pref", ethnicity ? `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : "", ethnicity), + tooltipRow("Climate mismatch", mismatch.toFixed(2), agent), + tooltipRow("Sedentary", agent ? agent.traits.sedentary.toFixed(2) : "", agent), + tooltipRow("Ethnocentrism", agent ? agent.traits.ethnocentrism.toFixed(2) : "", agent), + tooltipRow("Farming", agent ? `${techLevel(agent, "farming").toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : "", agent), + tooltipRow("Metallurgy", techLevel(agent, "metallurgy").toFixed(3), agent), + tooltipRow("Stored", agent?.resources.toFixed(1), agent) ])} `; els.tooltip.hidden = false; @@ -3566,6 +3826,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 ` +
+
+
${labels}
+
+`; +} function hideTooltip() { hoverState = null; els.tooltip.hidden = true; @@ -3600,23 +3892,28 @@ let farmingHolders = 0; let metallurgyHolders = 0; for (const a of sim.agents) { sim.ensureAgentTech(a); -farmingTotal += a.tech.farming || 0; -metallurgyTotal += a.tech.metallurgy || 0; -if ((a.tech.farming || 0) > 0.02) farmingHolders++; -if ((a.tech.metallurgy || 0) > 0.02) metallurgyHolders++; +farmingTotal += techLevel(a, "farming"); +metallurgyTotal += techLevel(a, "metallurgy"); +if (techLevel(a, "farming") > 0.02) farmingHolders++; +if (techLevel(a, "metallurgy") > 0.02) metallurgyHolders++; } const agentCount = Math.max(1, sim.agents.length); -els.year.textContent = formatSimDate(sim.year); -els.activeGroups.textContent = sim.agents.length.toLocaleString(); -els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString(); -els.ethnicities.textContent = livingEthnicities.length.toLocaleString(); -els.cities.textContent = sim.cities.length.toLocaleString(); -els.polities.textContent = sim.polities.length.toLocaleString(); -if (els.wars) els.wars.textContent = sim.wars.length.toLocaleString(); -els.routes.textContent = sim.tradeLinks.length.toLocaleString(); -els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`; -els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`; -els.deaths.textContent = sim.deaths.toLocaleString(); +const stats = { +year: formatSimDate(sim.year), +activeGroups: sim.agents.length.toLocaleString(), +urbanPopulation: Math.floor(urbanPopulation).toLocaleString(), +ethnicities: livingEthnicities.length.toLocaleString(), +cities: sim.cities.length.toLocaleString(), +polities: sim.polities.length.toLocaleString(), +wars: sim.wars.length.toLocaleString(), +routes: sim.tradeLinks.length.toLocaleString(), +farmingKnowledge: `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`, +metallurgyKnowledge: `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`, +deaths: sim.deaths.toLocaleString() +}; +for (const [id, value] of Object.entries(stats)) { +if (els[id]) els[id].textContent = value; +} const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9); els.ethnicityList.innerHTML = top.map(e => ( `
  • ` + @@ -3626,19 +3923,7 @@ maybeRenderStateGraph(); } function setLegend() { const mode = els.viewMode.value; -if (mode === "terrain") { -els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join(""); -} else if (mode === "ethnicity") { -els.legend.innerHTML = "Agent and city colors show lineage. Land remains terrain-colored."; -} else if (mode === "pressure") { -els.legend.innerHTML = "High local population pressure"; -} else if (mode === "polities") { -els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent."; -} else if (mode === "technology") { -els.legend.innerHTML = "Farming knowledgeMetallurgy knowledge"; -} else { -els.legend.innerHTML = "Regenerating local resource stock"; -} +els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)(); } function maybeRenderStateGraph() { const now = performance.now(); @@ -3652,17 +3937,29 @@ return Math.sqrt(sample?.population || 0) * 1.35 + Math.sqrt(Math.max(0, sample?.treasury || 0)) * 1.15 + (sample?.avgLoyalty ?? 0.5) * 16; } +const graphMetrics = { +power: { +value: sampleGraphPower, +format: value => value.toFixed(1) +}, +population: { +value: sample => sample?.population || 0, +format: value => Math.round(value).toLocaleString() +}, +cities: { +value: sample => sample?.cities || 0, +format: value => Math.round(value).toLocaleString() +}, +loyalty: { +value: sample => sample?.avgLoyalty ?? 0.5, +format: value => `${Math.round(clamp(value, 0, 1) * 100)}%` +} +}; function graphSampleValue(sample, metric) { -if (metric === "population") return sample?.population || 0; -if (metric === "cities") return sample?.cities || 0; -if (metric === "loyalty") return sample?.avgLoyalty ?? 0.5; -return sampleGraphPower(sample); +return (graphMetrics[metric] || graphMetrics.power).value(sample); } function formatGraphValue(value, metric) { -if (metric === "loyalty") return `${Math.round(clamp(value, 0, 1) * 100)}%`; -if (metric === "cities") return Math.round(value).toLocaleString(); -if (metric === "population") return Math.round(value).toLocaleString(); -return value.toFixed(1); +return (graphMetrics[metric] || graphMetrics.power).format(value); } function renderStateGraph() { const canvas = els.stateGraph; @@ -3827,10 +4124,7 @@ graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, wid const axisY = h - paddingBottom + 4; g.strokeStyle = "rgba(168, 177, 170, 0.22)"; g.lineWidth = 1; -g.beginPath(); -g.moveTo(paddingLeft, axisY); -g.lineTo(w - paddingRight, axisY); -g.stroke(); +strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY); g.fillStyle = "#8c9690"; g.font = "10px ui-sans-serif, system-ui, sans-serif"; g.textAlign = "left"; @@ -3839,26 +4133,34 @@ g.textAlign = "center"; g.fillText(formatGraphYear((minYear + maxYear) / 2), paddingLeft + plotWidth / 2, h - 5); g.textAlign = "right"; g.fillText(formatGraphYear(maxYear), w - paddingRight, h - 5); +const topEvents = (sim.graphEvents || []) +.filter(event => event.importance >= 2) +.filter(event => event.year >= minYear && event.year <= maxYear) +.filter(event => graphEventTypes[event.type]); +const topCollisions = new Map(); +for (const event of topEvents) { +const x = yearToX(event.year); +const bucket = Math.round(x / 7); +const count = topCollisions.get(bucket) || 0; +topCollisions.set(bucket, count + 1); +const y = Math.max(7, paddingTop * 0.5 + ([-3, 0, 3][count % 3])); +const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; +const graphEvent = graphEventTypes[event.type]; +g.fillStyle = graphEvent.color; +graphEvent.draw(g, x, y, r); +graphState.markers.push({ row: null, event, x, y, radius: r + 3 }); +} rows.forEach(row => { +const alpha = row.type === "group" ? 0.16 : 0.12; if (row.type === "group") { g.fillStyle = "#7f8984"; g.font = "10px ui-sans-serif, system-ui, sans-serif"; g.textAlign = "left"; g.fillText(row.label, 4, row.y + 3); -g.strokeStyle = "rgba(168, 177, 170, 0.16)"; -g.lineWidth = 1; -g.beginPath(); -g.moveTo(paddingLeft, row.y); -g.lineTo(w - paddingRight, row.y); -g.stroke(); -return; } -g.strokeStyle = "rgba(168, 177, 170, 0.12)"; +g.strokeStyle = `rgba(168, 177, 170, ${alpha})`; g.lineWidth = 1; -g.beginPath(); -g.moveTo(paddingLeft, row.y); -g.lineTo(w - paddingRight, row.y); -g.stroke(); +strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y); }); rows.forEach(row => { if (row.type !== "state") return; @@ -3882,16 +4184,10 @@ const thickness = clamp(3.5 + cityT * 5.5 + metricT * 4.5, 3.5, 13); g.lineWidth = thickness; g.lineCap = "round"; g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${clamp(alpha * 0.28, 0.08, 0.28)})`; -g.beginPath(); -g.moveTo(yearToX(fromYear), row.y); -g.lineTo(yearToX(toYear), row.y); -g.stroke(); +strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y); g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`; g.lineWidth = Math.max(2, thickness * 0.62); -g.beginPath(); -g.moveTo(yearToX(fromYear), row.y); -g.lineTo(yearToX(toYear), row.y); -g.stroke(); +strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y); g.lineCap = "butt"; }; if (row.samples.length > 1) { @@ -3923,7 +4219,7 @@ if (row.type !== "state") return; const events = (row.history.events || []) .filter(event => event.importance >= 2) .filter(event => event.year >= minYear && event.year <= maxYear) -.filter(event => ["capitalShift", "disaster", "peak", "famine"].includes(event.type)); +.filter(event => graphEventTypes[event.type]); const collisions = new Map(); for (const event of events) { const x = yearToX(event.year); @@ -3932,19 +4228,9 @@ const count = collisions.get(bucket) || 0; collisions.set(bucket, count + 1); const y = row.y + ([-4, 0, 4][count % 3]); const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; -if (event.type === "capitalShift") { -g.fillStyle = "rgba(227, 179, 65, 0.95)"; -drawGraphDiamond(g, x, y, r); -} else if (event.type === "disaster") { -g.fillStyle = "rgba(211, 95, 84, 0.95)"; -drawGraphCircle(g, x, y, r); -} else if (event.type === "peak") { -g.fillStyle = "rgba(238, 241, 237, 0.90)"; -drawGraphTriangleUp(g, x, y, r); -} else if (event.type === "famine") { -g.fillStyle = "rgba(227, 128, 65, 0.90)"; -drawGraphTriangleDown(g, x, y, r); -} +const graphEvent = graphEventTypes[event.type]; +g.fillStyle = graphEvent.color; +graphEvent.draw(g, x, y, r); graphState.markers.push({ row, event, x, y, radius: r + 3 }); } }); @@ -3954,58 +4240,67 @@ g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill(); } -function drawGraphDiamond(g, x, y, r) { +function drawGraphPolygon(g, points) { g.beginPath(); -g.moveTo(x, y - r); -g.lineTo(x + r, y); -g.lineTo(x, y + r); -g.lineTo(x - r, y); +g.moveTo(points[0][0], points[0][1]); +for (let i = 1; i < points.length; i++) g.lineTo(points[i][0], points[i][1]); g.closePath(); g.fill(); } +function drawGraphDiamond(g, x, y, r) { +drawGraphPolygon(g, [[x, y - r], [x + r, y], [x, y + r], [x - r, y]]); +} function drawGraphTriangleUp(g, x, y, r) { -g.beginPath(); -g.moveTo(x, y - r); -g.lineTo(x + r, y + r); -g.lineTo(x - r, y + r); -g.closePath(); -g.fill(); +drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]); } function drawGraphTriangleDown(g, x, y, r) { -g.beginPath(); -g.moveTo(x, y + r); -g.lineTo(x + r, y - r); -g.lineTo(x - r, y - r); -g.closePath(); -g.fill(); +drawGraphPolygon(g, [[x, y + r], [x + r, y - r], [x - r, y - r]]); } +const graphEventTypes = { +capitalShift: { +label: "capital shift", +color: "rgba(227, 179, 65, 0.95)", +draw: drawGraphDiamond, +summary: data => `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}` +}, +disaster: { +label: "major disaster", +color: "rgba(211, 95, 84, 0.95)", +draw: drawGraphCircle, +summary: data => `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities` +}, +famine: { +label: "famine", +color: "rgba(227, 128, 65, 0.90)", +draw: drawGraphTriangleDown, +summary: data => `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap` +}, +rebellion: { +label: "rebellion", +color: "rgba(236, 126, 111, 0.95)", +draw: drawGraphTriangleUp, +summary: data => `city #${data.cityId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}` +}, +newEthnicity: { +label: "new ethnicity", +color: "rgba(105, 181, 120, 0.95)", +draw: drawGraphDiamond, +summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier groups` +} +}; function graphEventLabel(event) { -if (event.type === "capitalShift") return "capital shift"; -if (event.type === "disaster") return "major disaster"; -if (event.type === "peak") return "peak power"; -if (event.type === "famine") return "famine"; -return event.type; +return graphEventTypes[event.type]?.label || event.type; } function graphEventSummary(event) { -const data = event.data || {}; -if (event.type === "capitalShift") { -return `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`; -} -if (event.type === "disaster") { -return `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`; -} -if (event.type === "peak") { -return `power ${Number(data.power || 0).toFixed(1)}, pop ${Math.round(data.population || 0).toLocaleString()}, cities ${data.cities || 0}`; -} -if (event.type === "famine") { -return `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`; -} -return ""; +return graphEventTypes[event.type]?.summary(event.data || {}) || ""; } function showStateGraphMarkerTooltip(marker, pointerX, pointerY) { if (!els.stateGraphTooltip) return; +const title = marker.row +? `S${marker.row.history.id} ${graphEventLabel(marker.event)}` +: graphEventLabel(marker.event); els.stateGraphTooltip.innerHTML = ` - S${marker.row.history.id} ${graphEventLabel(marker.event)} + ${title} ${formatGraphYear(marker.event.year)} ${graphEventSummary(marker.event)} `; @@ -4026,23 +4321,32 @@ const y = event.offsetY; const x = event.offsetX; const marker = (graphState.markers || []) .map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) })) -.filter(item => item.distance <= item.candidate.radius) -.sort((a, b) => a.distance - b.distance)[0]?.candidate; -if (marker) { -const status = marker.row.history.active && marker.row.history.ended === null ? "living" : "past"; -els.stateGraphInfo.textContent = `S${marker.row.history.id} ${status} ${formatGraphYear(marker.event.year)} ${graphEventLabel(marker.event)}`; -showStateGraphMarkerTooltip(marker, x, y); -if (graphState.hoverPolityId !== marker.row.history.id) { -graphState.hoverPolityId = marker.row.history.id; +.filter(item => item.distance <= item.candidate.radius); +const nearestMarker = bestBy(marker, item => -item.distance)?.candidate; +if (nearestMarker) { +if (!nearestMarker.row) { +els.stateGraphInfo.textContent = `${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`; +showStateGraphMarkerTooltip(nearestMarker, x, y); +if (graphState.hoverPolityId !== null) { +graphState.hoverPolityId = null; +if (!running) render(); +} +return; +} +const status = nearestMarker.row.history.active && nearestMarker.row.history.ended === null ? "living" : "past"; +els.stateGraphInfo.textContent = `S${nearestMarker.row.history.id} ${status} ${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`; +showStateGraphMarkerTooltip(nearestMarker, x, y); +if (graphState.hoverPolityId !== nearestMarker.row.history.id) { +graphState.hoverPolityId = nearestMarker.row.history.id; if (!running) render(); } return; } const row = graphState.rows .map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) })) -.filter(item => item.distance <= item.candidate.height * 0.5) -.sort((a, b) => a.distance - b.distance)[0]?.candidate; -if (!row) { +.filter(item => item.distance <= item.candidate.height * 0.5); +const nearestRow = bestBy(row, item => -item.distance)?.candidate; +if (!nearestRow) { els.stateGraphInfo.textContent = "Hover a row for details"; hideStateGraphMarkerTooltip(); if (graphState.hoverPolityId !== null) { @@ -4051,26 +4355,24 @@ if (!running) render(); } return; } -if (graphState.hoverPolityId !== row.history.id) { -graphState.hoverPolityId = row.history.id; +if (graphState.hoverPolityId !== nearestRow.history.id) { +graphState.hoverPolityId = nearestRow.history.id; if (!running) render(); } hideStateGraphMarkerTooltip(); const year = graphState.scale.xToYear(x); -const samples = row.samples.length ? row.samples : [{ +const samples = nearestRow.samples.length ? nearestRow.samples : [{ year, population: 0, -cities: row.peakCities, +cities: nearestRow.peakCities, avgLoyalty: 0.5, -power: row.peakPower +power: nearestRow.peakPower }]; -const sample = samples -.map(candidate => ({ candidate, distance: Math.abs((candidate.year ?? year) - year) })) -.sort((a, b) => a.distance - b.distance)[0].candidate; +const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year)); const metricValue = graphSampleValue(sample, graphState.metric); -const status = row.history.active && row.history.ended === null ? "living" : "past"; +const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past"; els.stateGraphInfo.textContent = -`S${row.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` + +`S${nearestRow.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` + `${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` + `pop ${Math.round(sample.population || 0).toLocaleString()} ` + `cities ${Math.round(sample.cities || 0).toLocaleString()} ` + @@ -4084,13 +4386,15 @@ graphState.hoverPolityId = null; if (!running) render(); } } -function formatGraphYear(month) { -return `${Math.floor(month / MONTHS_PER_YEAR).toLocaleString()}y`; +function formatGraphYear(week) { +return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`; } -function formatSimDate(month) { -const year = Math.floor(month / MONTHS_PER_YEAR); -const monthOfYear = month % MONTHS_PER_YEAR + 1; -return `${year.toLocaleString()}y ${monthOfYear}m`; +function formatSimDate(week) { +const year = Math.floor(week / WEEKS_PER_YEAR); +const weekOfYear = week % WEEKS_PER_YEAR; +const monthOfYear = Math.floor(weekOfYear / WEEKS_PER_MONTH) + 1; +const weekOfMonth = weekOfYear % WEEKS_PER_MONTH + 1; +return `${year.toLocaleString()}y ${monthOfYear}m ${weekOfMonth}w`; } function requestRender() { renderDirty = true; @@ -4122,19 +4426,19 @@ loopScheduled = false; if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) { lastSimTickAt = now; const steps = Number(els.speed.value); +const stepBudget = LoopConfig.stepBudgetMs * Math.max(1, steps); const loopStart = performance.now(); let completedSteps = 0; for (let i = 0; i < steps; i++) { sim.step(); completedSteps++; -if (completedSteps > 0 && performance.now() - loopStart > LoopConfig.stepBudgetMs) break; +if (completedSteps > 0 && performance.now() - loopStart > stepBudget) break; } if (completedSteps > 0) requestRender(); } const shouldRender = !document.hidden && (renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs)); if (shouldRender) drawFrame(); -frame++; scheduleLoop(); } function reset() { @@ -4152,64 +4456,69 @@ renderStateGraph(); function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } +function bestBy(items, score) { +let best = null; +let bestScore = -Infinity; +for (const item of items) { +const value = score(item); +if (value > bestScore) { +best = item; +bestScore = value; +} +} +return best; +} function smoothstep(t) { return t * t * (3 - 2 * t); } -function getSedentary(traits) { -return traits.sedentary; -} -function getEthnocentrism(traits) { -return traits.ethnocentrism; -} +const traitFields = [ +["mobility", traits => traits.mobility, 0.02, 1, 1, 1], +["resourceAttraction", traits => traits.resourceAttraction, 0.05, 1.2, 1, 1], +["assimilation", traits => traits.assimilation, 0, 0.75, 1, 1], +["ethnocentrism", traits => traits.ethnocentrism, 0, 1.2, 1, 1], +["reproductionThreshold", traits => traits.reproductionThreshold, 12, 48, 16, 1 / 48], +["sedentary", traits => traits.sedentary, 0, 1, 1, 1] +]; function mutateTraits(traits, rng, amount) { -return { -mobility: clamp(traits.mobility + rng.range(-amount, amount), 0.02, 1), -resourceAttraction: clamp(traits.resourceAttraction + rng.range(-amount, amount), 0.05, 1.2), -assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75), -ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2), -reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48), -sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1) -}; +return Object.fromEntries(traitFields.map(([key, get, min, max, mutationScale]) => [ +key, +clamp(get(traits) + rng.range(-amount * mutationScale, amount * mutationScale), min, max) +])); } function blendTraits(a, b, t) { -return { -mobility: lerp(a.mobility, b.mobility, t), -resourceAttraction: lerp(a.resourceAttraction, b.resourceAttraction, t), -assimilation: lerp(a.assimilation, b.assimilation, t), -ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t), -reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t), -sedentary: lerp(getSedentary(a), getSedentary(b), t) -}; +return Object.fromEntries(traitFields.map(([key, get]) => [key, lerp(get(a), get(b), t)])); } function emptyTraitSums() { -return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, sedentary: 0 }; +return Object.fromEntries(traitFields.map(([key]) => [key, 0])); } function addTraits(sum, traits) { -sum.mobility += traits.mobility; -sum.resourceAttraction += traits.resourceAttraction; -sum.assimilation += traits.assimilation; -sum.ethnocentrism += getEthnocentrism(traits); -sum.reproductionThreshold += traits.reproductionThreshold / 48; -sum.sedentary += getSedentary(traits); +for (const [key, get, , , , sumScale] of traitFields) sum[key] += get(traits) * sumScale; } function averageTraits(sum, count) { -return { -mobility: sum.mobility / count, -resourceAttraction: sum.resourceAttraction / count, -assimilation: sum.assimilation / count, -ethnocentrism: sum.ethnocentrism / count, -reproductionThreshold: (sum.reproductionThreshold / count) * 48, -sedentary: sum.sedentary / count -}; +return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale])); } -function addBirthsToComposition(composition, births) { -const dominant = dominantComposition(composition); -if (!dominant) return; -composition.set(dominant, (composition.get(dominant) || 0) + births); +function compositionTotal(composition) { +return [...composition.values()].reduce((sum, value) => sum + value, 0); +} +function addBirthsToComposition(composition, births, rng = null) { + const entries = [...composition.entries()]; + const total = entries.reduce((sum, [, value]) => sum + value, 0); + if (!total || births <= 0) return; + + for (let n = 0; n < births; n++) { + let r = (rng ? rng.next() : Math.random()) * total; + for (const [id, count] of entries) { + r -= count; + if (r <= 0) { + composition.set(id, (composition.get(id) || 0) + 1); + break; + } + } + } } function removeFromComposition(composition, loss) { let remaining = loss; -const total = [...composition.values()].reduce((sum, value) => sum + value, 0); +const total = compositionTotal(composition); if (!total) return; for (const [id, count] of [...composition]) { const removed = Math.min(count, Math.ceil(loss * (count / total))); @@ -4231,12 +4540,7 @@ bestCount = count; return bestId; } function traitDistance(a, b) { -return Math.abs(a.mobility - b.mobility) + -Math.abs(a.resourceAttraction - b.resourceAttraction) + -Math.abs(a.assimilation - b.assimilation) + -Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) + -Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 + -Math.abs(getSedentary(a) - getSedentary(b)); +return traitFields.reduce((sum, [, get, , , , distanceScale]) => sum + Math.abs(get(a) - get(b)) * distanceScale, 0); } function hslToRgb(h, s, l) { const hue = (p, q, t) => { @@ -4265,70 +4569,9 @@ Math.round(lerp(a[2], b[2], t)) function lerp(a, b, t) { return a + (b - a) * t; } -function packArray(typedArray) { -const bytes = new Uint8Array(typedArray.buffer); -let binary = ""; -const chunkSize = 8192; -for (let i = 0; i < bytes.length; i += chunkSize) { -binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); -} -return { -type: typedArray.constructor.name, -length: typedArray.length, -data: btoa(binary) -}; -} -function unpackArray(payload, TypedArray) { -const binary = atob(payload.data); -const bytes = new Uint8Array(binary.length); -for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); -return new TypedArray(bytes.buffer, 0, payload.length); -} -const Persistence = Object.freeze({ -save(currentSim) { -const raw = JSON.stringify(currentSim); -if (raw.length > SimConfig.save.maxBytes) { -throw new Error(`Save is too large (${raw.length.toLocaleString()} bytes)`); -} -localStorage.setItem(SimConfig.save.key, raw); -}, -load() { -const raw = localStorage.getItem(SimConfig.save.key); -if (!raw) return null; -if (raw.length > SimConfig.save.maxBytes * 1.25) { -throw new Error("Saved world is too large to load safely"); -} -const state = JSON.parse(raw); -if (!state || !state.world || !state.agents) throw new Error("Saved world is missing required fields"); -return Simulation.fromJSON(state); -}, -clear() { -localStorage.removeItem(SimConfig.save.key); -} -}); -function saveWorld() { -try { -Persistence.save(sim); -} catch (error) { -console.warn("Save failed", error); -} -} -function loadWorld() { -try { -const loaded = Persistence.load(); -if (!loaded) return; -sim = loaded; -els.worldSize.value = String(sim.world.size); -els.canvas.width = sim.world.size; -els.canvas.height = sim.world.size; -renderImage = null; -renderImageSize = 0; -setLegend(); -drawFrame(true); +function refreshStateGraph() { renderStateGraph(); -} catch (error) { -console.warn("Load failed", error); -} +clearStateGraphInfo(); } els.toggleRun.addEventListener("click", () => { running = !running; @@ -4350,11 +4593,7 @@ drawFrame(true); renderStateGraph(); }); els.resetWorld.addEventListener("click", reset); -els.saveWorld.addEventListener("click", saveWorld); -els.loadWorld.addEventListener("click", loadWorld); -els.clearSave.addEventListener("click", () => Persistence.clear()); -els.worldSize.addEventListener("change", reset); -els.agentCount.addEventListener("change", reset); +for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset); els.viewMode.addEventListener("change", () => { setLegend(); requestRender(); @@ -4363,22 +4602,17 @@ drawFrame(); els.canvas.addEventListener("mousemove", showTooltip); els.canvas.addEventListener("mouseleave", hideTooltip); for (const control of [els.historyFilter, els.historySort, els.historyMetric]) { -control?.addEventListener("change", () => { -renderStateGraph(); -clearStateGraphInfo(); -}); +control?.addEventListener("change", refreshStateGraph); } els.toggleActiveStates?.addEventListener("click", () => { graphState.showActive = !graphState.showActive; els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); -renderStateGraph(); -clearStateGraphInfo(); +refreshStateGraph(); }); els.togglePastStates?.addEventListener("click", () => { graphState.showPast = !graphState.showPast; els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); -renderStateGraph(); -clearStateGraphInfo(); +refreshStateGraph(); }); els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); diff --git a/styles.css b/styles.css index 1e8814b..1b770d9 100644 --- a/styles.css +++ b/styles.css @@ -412,6 +412,43 @@ canvas#world { text-align: right; } +.ethnicity-pie-wrap { + display: grid; + grid-template-columns: 44px 1fr; + align-items: center; + gap: 8px; + margin-top: 6px; +} + +.ethnicity-pie { + width: 40px; + height: 40px; + border: 1px solid rgba(238, 241, 237, 0.28); + border-radius: 50%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.ethnicity-pie-labels { + display: flex; + flex-wrap: wrap; + gap: 3px 7px; +} + +.tooltip .ethnicity-pie-key { + display: inline-flex; + justify-content: flex-start; + align-items: center; + gap: 4px; + color: var(--muted); + white-space: nowrap; +} + +.ethnicity-pie-key i { + width: 8px; + height: 8px; + border-radius: 2px; +} + @media (max-width: 1100px) { body { overflow: auto;