diff --git a/app-state.js b/app-state.js new file mode 100644 index 0000000..2974c27 --- /dev/null +++ b/app-state.js @@ -0,0 +1,50 @@ +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 lastStatsAt = 0; +let lastGraphRenderAt = 0; +let lastRenderAt = 0; +let lastSimTickAt = 0; +let renderDirty = true; +let loopTimer = null; +let loopScheduled = false; +let hoverState = null; +let renderImage = null; +let renderImageSize = 0; +const LoopConfig = Object.freeze({ +simTickMs: 33, +renderMs: 66, +stepBudgetMs: 4.5, +idlePollMs: 180, +hiddenPollMs: 1000 +}); +const graphState = { +filter: "all", +sort: "oldest", +metric: "power", +showActive: true, +showPast: true, +hoverPolityId: null, +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" +}; diff --git a/boot.js b/boot.js new file mode 100644 index 0000000..7a24abc --- /dev/null +++ b/boot.js @@ -0,0 +1,116 @@ +function requestRender() { +renderDirty = true; +} +function drawFrame(forceStats = false) { +render(); +updateStats(forceStats); +renderTooltip(); +renderDirty = false; +lastRenderAt = performance.now(); +} +function scheduleLoop() { +if (loopScheduled) return; +loopScheduled = true; +const delay = document.hidden +? LoopConfig.hiddenPollMs +: running ? 0 : LoopConfig.idlePollMs; +if (delay > 0) { +loopTimer = setTimeout(() => { +loopTimer = null; +requestAnimationFrame(loop); +}, delay); +} else { +requestAnimationFrame(loop); +} +} +function loop(now = performance.now()) { +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 > stepBudget) break; +} +if (completedSteps > 0) requestRender(); +} +const shouldRender = !document.hidden && +(renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs)); +if (shouldRender) drawFrame(); +scheduleLoop(); +} +function reset() { +const size = Number(els.worldSize.value); +const count = Number(els.agentCount.value); +els.canvas.width = size; +els.canvas.height = size; +renderImage = null; +renderImageSize = 0; +sim = new Simulation(size, count); +setLegend(); +drawFrame(true); +renderStateGraph(); +} +function refreshStateGraph() { +renderStateGraph(); +clearStateGraphInfo(); +} +els.toggleRun.addEventListener("click", () => { +running = !running; +els.toggleRun.textContent = running ? "Pause" : "Run"; +lastSimTickAt = 0; +if (running && loopTimer) { +clearTimeout(loopTimer); +loopTimer = null; +loopScheduled = false; +scheduleLoop(); +} else if (running) { +scheduleLoop(); +} +}); +els.stepOnce.addEventListener("click", () => { +sim.step(); +requestRender(); +drawFrame(true); +renderStateGraph(); +}); +els.resetWorld.addEventListener("click", reset); +for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset); +els.viewMode.addEventListener("change", () => { +setLegend(); +requestRender(); +drawFrame(); +}); +els.canvas.addEventListener("mousemove", showTooltip); +els.canvas.addEventListener("mouseleave", hideTooltip); +for (const control of [els.historyFilter, els.historySort, els.historyMetric]) { +control?.addEventListener("change", refreshStateGraph); +} +els.toggleActiveStates?.addEventListener("click", () => { +graphState.showActive = !graphState.showActive; +els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); +refreshStateGraph(); +}); +els.togglePastStates?.addEventListener("click", () => { +graphState.showPast = !graphState.showPast; +els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); +refreshStateGraph(); +}); +els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); +els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); +document.addEventListener("visibilitychange", () => { +lastSimTickAt = 0; +requestRender(); +if (!document.hidden && loopTimer) { +clearTimeout(loopTimer); +loopTimer = null; +loopScheduled = false; +scheduleLoop(); +} +}); +reset(); +loop(); diff --git a/config.js b/config.js new file mode 100644 index 0000000..2ec80df --- /dev/null +++ b/config.js @@ -0,0 +1,110 @@ +const Terrain = Object.freeze({ +PLAINS: 0, +FOREST: 1, +MOUNTAIN: 2, +WATER: 3, +DESERT: 4 +}); +const terrainInfo = [ +{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 }, +{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 }, +{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 }, +{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 }, +{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 } +]; +const WEEKS_PER_MONTH = 4; +const MONTHS_PER_YEAR = 12; +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, +carryingCapacityMineral: 1.8, +carryingCapacityFarmland: 5.5, +pressurePenaltyBase: 0.2, +pressureReproductionPenalty: 0.16 +}), +city: Object.freeze({ +maxCities: 300 +}), +render: Object.freeze({ +graphThrottleMs: 1400, +statsThrottleMs: 900, +historyWindowYears: 2000, +historySamplePaddingYears: 240 +}), +technology: Object.freeze({ +cityDiffusion: 0.0022, +tradeDiffusion: 0.0032, +cityInnovation: 0.00045 +}), +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.76, +minRadius: 6, +maxRadius: 34, +rareLargeChance: 0.10, +largeRadiusMin: 28, +largeRadiusMax: 54, +minIntensity: 0.25, +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 * WEEKS_PER_YEAR; +} diff --git a/script.js b/engine.js similarity index 71% rename from script.js rename to engine.js index d843674..3d1c45e 100644 --- a/script.js +++ b/engine.js @@ -1,167 +1,3 @@ -const Terrain = Object.freeze({ -PLAINS: 0, -FOREST: 1, -MOUNTAIN: 2, -WATER: 3, -DESERT: 4, -FERTILE: 5, -MINERAL: 6 -}); -const terrainInfo = [ -{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 }, -{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 }, -{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 }, -{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 }, -{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 }, -{ name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 }, -{ name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 } -]; -const WEEKS_PER_MONTH = 4; -const MONTHS_PER_YEAR = 12; -const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH; -const 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, -carryingCapacityMineral: 1.8, -carryingCapacityFarmland: 5.5, -pressurePenaltyBase: 0.2, -pressureReproductionPenalty: 0.16 -}), -city: Object.freeze({ -maxCities: 300 -}), -render: Object.freeze({ -graphThrottleMs: 1400, -statsThrottleMs: 900, -historyWindowYears: 2000, -historySamplePaddingYears: 240 -}), -technology: Object.freeze({ -cityDiffusion: 0.0022, -tradeDiffusion: 0.0032, -cityInnovation: 0.00045 -}), -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.76, -minRadius: 6, -maxRadius: 34, -rareLargeChance: 0.10, -largeRadiusMin: 28, -largeRadiusMax: 54, -minIntensity: 0.25, -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 * WEEKS_PER_YEAR; -} -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 lastStatsAt = 0; -let lastGraphRenderAt = 0; -let lastRenderAt = 0; -let lastSimTickAt = 0; -let renderDirty = true; -let loopTimer = null; -let loopScheduled = false; -let hoverState = null; -let renderImage = null; -let renderImageSize = 0; -const LoopConfig = Object.freeze({ -simTickMs: 33, -renderMs: 66, -stepBudgetMs: 4.5, -idlePollMs: 180, -hiddenPollMs: 1000 -}); -const graphState = { -filter: "all", -sort: "important", -metric: "power", -showActive: true, -showPast: true, -hoverPolityId: null, -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; @@ -254,9 +90,8 @@ const temp = this.temperature[i]; const humid = this.humidity[i]; let t = this.terrain[i]; if (t !== Terrain.WATER) { -if (elevation > seaLevel + 0.42) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN; +if (elevation > seaLevel + 0.42) t = Terrain.MOUNTAIN; else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT; -else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE; else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST; else t = Terrain.PLAINS; } @@ -294,7 +129,6 @@ if (waterScore <= 0) continue; const boost = Math.min(0.32, waterScore * 0.08); nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1); if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS; -if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE; } } for (let i = 0; i < this.count; i++) { @@ -1031,7 +865,6 @@ 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; if (t === Terrain.PLAINS) return 0.75; if (t === Terrain.FOREST) return 0.55; if (t === Terrain.DESERT) return 0.25; @@ -1095,7 +928,7 @@ if ((agent.tech?.metallurgy || 0) <= 0.02) { const metallurgyBase = 0.00011; const mineral = clamp(w.mineral[tile], 0, 1); const mineralTerrainBonus = -w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN +w.terrain[tile] === Terrain.MOUNTAIN ? 1.8 : 1.0; const metallurgyChance = @@ -1543,7 +1376,7 @@ for (const a of this.agents) { if (!a.alive) continue; const local = w.idx(a.x, a.y); if (a.settled < 3 && w.pressure[local] < 2) continue; -if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue; +if (w.terrain[local] === Terrain.WATER) continue; const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1); const cy = clamp(Math.round(a.y / 4) * 4, 0, w.size - 1); const i = w.idx(cx, cy); @@ -2955,6 +2788,7 @@ for (const polity of this.polities) { const instability = this.polityInstability(polity); const agePressure = this.polityAgePressure(polity); const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28); +const rebellions = []; for (const city of this.getPolityCities(polity)) { if (city.id === polity.centerCityId || city.loyalty >= threshold) continue; const chance = @@ -2964,13 +2798,25 @@ agePressure * 0.010; if (this.rng.next() < chance) { const rebellionLoyalty = city.loyalty ?? 0; this.removeCityFromPolity(city); -this.addPolityEvent(polity.id, "rebellion", this.year, { +rebellions.push({ cityId: city.id, population: city.population || 0, loyalty: rebellionLoyalty -}, 3); +}); } } +if (rebellions.length === 1) { +this.addPolityEvent(polity.id, "rebellion", this.year, rebellions[0], 1); +} else if (rebellions.length > 1) { +const population = rebellions.reduce((sum, rebellion) => sum + (rebellion.population || 0), 0); +const avgLoyalty = rebellions.reduce((sum, rebellion) => sum + (rebellion.loyalty || 0), 0) / rebellions.length; +this.addPolityEvent(polity.id, "rebellion", this.year, { +cityIds: rebellions.map(rebellion => rebellion.cityId), +cityCount: rebellions.length, +population, +loyalty: avgLoyalty +}, 3); +} } } enforcePolityContinuity(polity) { @@ -2993,7 +2839,7 @@ const isolated = cities.filter(city => !reachable.has(city.id)); if (!isolated.length) return cities; for (const city of isolated) { this.removeCityFromPolity(city); -city.loyalty = clamp(city.loyalty - 0.12, 0, 1); +city.loyalty = clamp(city.loyalty - 0.28, 0, 1); } polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1); @@ -3497,1134 +3343,3 @@ for (const a of candidates) a.ethnicity = newId; } } } -function render() { -const start = performance.now(); -const w = sim.world; -const size = w.size; -if (!renderImage || renderImageSize !== size) { -renderImage = ctx.createImageData(size, size); -renderImageSize = size; -} -const image = renderImage; -const data = image.data; -const mode = els.viewMode.value; -const cityPolityColor = new Map(); -if (mode === "polities") { -for (const city of sim.cities) { -if (city.polityId === null) continue; -const polity = cityPolity(city); -if (!polity) continue; -const isGraphHover = graphState.hoverPolityId === polity.id; -cityPolityColor.set(city.id, { -color: polity.color, -isGraphHover -}); -} -} -for (let i = 0; i < w.count; i++) { -let color; -if (mode === "resources") { -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); -} else if (mode === "polities") { -color = terrainInfo[w.terrain[i]].color; -if (w.city[i] >= 0) { -const polityColor = cityPolityColor.get(w.city[i]); -if (polityColor) { -color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5); -if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28); -} -} -} 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; -} -const p = i * 4; -data[p] = color[0]; -data[p + 1] = color[1]; -data[p + 2] = color[2]; -data[p + 3] = 255; -} -ctx.putImageData(image, 0, 0); -renderDisasters(); -drawTradeLinks(); -drawAgentsAndCities(mode); -drawGraphPolityHighlight(); -maybeRenderStateGraph(); -els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; -} -function renderDisasters() { -if (!sim?.disasters?.length) return; -sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires); -if (!sim.disasters.length) return; -ctx.save(); -ctx.globalCompositeOperation = "source-over"; -for (const disaster of sim.disasters) { -const duration = Math.max(1, disaster.expires - disaster.year); -const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1); -const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22); -if (alpha <= 0.005) continue; -const innerRadius = Math.max(1, disaster.radius * 0.32); -const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius); -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; -drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius); -ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`; -ctx.lineWidth = 1; -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) { -ctx.restore(); -return; -} -for (const link of sim.tradeLinks) { -ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42); -ctx.fillStyle = "#ffd75a"; -for (const tile of link.path || []) { -ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); -} -} -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.72); -ctx.globalAlpha = 0.88; -fillSquare(ctx, city.x, city.y, radius); -} -} -ctx.globalAlpha = 1; -if (mode === "technology") { -for (const a of sim.agents) { -sim.ensureAgentTech(a); -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, clamp(0.32 + tech * 0.68, 0.32, 1)); -ctx.fillRect(a.x, a.y, 1, 1); -} -} else if (mode === "ethnicity") { -for (const a of sim.agents) { -const e = sim.ethnicities.get(a.ethnicity); -if (!e) continue; -ctx.fillStyle = rgb(e.color); -ctx.fillRect(a.x, a.y, 1, 1); -} -} else { -ctx.fillStyle = "#eeeccf"; -for (const tile of sim.tileAgents.keys()) { -ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); -} -} -for (const city of sim.cities) { -if (mode === "ethnicity") { -const radius = cityRenderRadius(city); -const color = cityDisplayColor(city, mode); -ctx.globalAlpha = 0.90; -ctx.fillStyle = rgba(color, 0.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(); -} -function drawGraphPolityHighlight() { -const polityId = graphState.hoverPolityId; -if (polityId === null) return; -const polity = sim.getPolityById(polityId); -if (!polity) return; -const cities = sim.getPolityCities(polity); -if (!cities.length) return; -ctx.save(); -ctx.lineWidth = 1; -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(color, 72); -ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); -ctx.globalAlpha = 0.32; -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(); -} -function cityRenderRadius(city) { -return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7); -} -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") { -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(); -const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size); -const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size); -hoverState = { -x, -y, -left: event.clientX - simRect.left + 16, -top: event.clientY - simRect.top + 16, -width: simRect.width, -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; -const y = hoverState.y; -if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) { -hideTooltip(); -return; -} -const w = sim.world; -const i = w.idx(x, y); -const agent = findAgentAt(x, y); -const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null; -const terrain = terrainInfo[w.terrain[i]]; -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 = cityPolity(city); -const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; -els.tooltip.innerHTML = ` - ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} - ${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]) - ])} - ${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) - ])} - ${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; -const margin = 8; -const width = els.tooltip.offsetWidth; -const height = els.tooltip.offsetHeight; -let left = hoverState.left; -let top = hoverState.top; -if (left + width + margin > hoverState.width) left = hoverState.left - width - 32; -if (top + height + margin > hoverState.height) top = hoverState.height - height - margin; -els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`; -els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; -} -function 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; -} -function findAgentAt(x, y) { -const directIndex = sim.world.idx(x, y); -return sim.tileAgents.get(directIndex)?.[0] || null; -} -function waterInfluenceAt(world, x, y) { -let score = 0; -for (let dy = -3; dy <= 3; dy++) { -for (let dx = -3; dx <= 3; dx++) { -const d = Math.abs(dx) + Math.abs(dy); -if (!d || d > 3) continue; -const tx = x + dx; -const ty = y + dy; -if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue; -if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4; -} -} -return score; -} -function updateStats(force = false) { -const now = performance.now(); -if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return; -lastStatsAt = now; -const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0); -const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0); -let farmingTotal = 0; -let metallurgyTotal = 0; -let farmingHolders = 0; -let metallurgyHolders = 0; -for (const a of sim.agents) { -sim.ensureAgentTech(a); -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); -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 => ( -`
  • ` + -`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}
  • ` -)).join(""); -maybeRenderStateGraph(); -} -function setLegend() { -const mode = els.viewMode.value; -els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)(); -} -function maybeRenderStateGraph() { -const now = performance.now(); -if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return; -lastGraphRenderAt = now; -renderStateGraph(); -} -function sampleGraphPower(sample) { -if (Number.isFinite(sample?.power)) return sample.power; -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) { -return (graphMetrics[metric] || graphMetrics.power).value(sample); -} -function formatGraphValue(value, metric) { -return (graphMetrics[metric] || graphMetrics.power).format(value); -} -function renderStateGraph() { -const canvas = els.stateGraph; -if (!canvas || !sim) return; -const rect = canvas.getBoundingClientRect(); -if (!rect.width) return; -const historyWindowSpan = years(SimConfig.render.historyWindowYears); -const historyWindowEnd = sim.year; -const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan); -graphState.filter = els.historyFilter?.value || graphState.filter; -graphState.sort = els.historySort?.value || graphState.sort; -graphState.metric = els.historyMetric?.value || graphState.metric; -const histories = sim.getAllPolityHistories() -.filter(history => (history.ended ?? sim.year) >= historyWindowStart) -.map(history => ({ -...history, -visibleFounded: Math.max(history.founded ?? 0, historyWindowStart), -visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart) -})) -.filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart) -.filter(history => { -const isActive = history.active && history.ended === null; -if (graphState.filter === "active" && !isActive) return false; -if (graphState.filter === "ended" && isActive) return false; -if (isActive && !graphState.showActive) return false; -if (!isActive && !graphState.showPast) return false; -return true; -}); -const paddingLeft = 34; -const paddingRight = 8; -const paddingTop = 18; -const paddingBottom = 22; -const rowHeight = 24; -const groupHeight = 18; -const scored = histories.map(history => { -const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0, avgLoyalty: 0.5 }]; -const peakPower = Math.max(...samples.map(sample => sampleGraphPower(sample))); -const peakPopulation = Math.max(...samples.map(sample => sample.population || 0)); -const peakCities = Math.max(...samples.map(sample => sample.cities || 0)); -const peakLoyalty = Math.max(...samples.map(sample => sample.avgLoyalty ?? 0.5)); -const peakMetric = Math.max(...samples.map(sample => graphSampleValue(sample, graphState.metric))); -const lifespan = (history.ended ?? sim.year) - history.visibleFounded; -const importance = peakPower * 1.6 + -peakCities * 130 + -Math.sqrt(Math.max(0, peakPopulation)) * 6 + -lifespan * 0.35; -return { history, peakPopulation, peakCities, peakLoyalty, peakPower, peakMetric, importance }; -}); -const compareRows = (a, b) => { -if (graphState.sort === "oldest") { -return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); -} -if (graphState.sort === "strongest") { -return (b.peakPower - a.peakPower) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)); -} -if (graphState.sort === "largest") { -return (b.peakPopulation - a.peakPopulation) || (b.peakCities - a.peakCities); -} -const activeDelta = (b.history.active ? 1 : 0) - (a.history.active ? 1 : 0); -if (graphState.sort === "active") { -return activeDelta || (b.importance - a.importance) || (a.history.id - b.history.id); -} -return activeDelta || (b.importance - a.importance) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); -}; -const selected = scored.sort(compareRows).slice(0, 90).sort(compareRows); -const activeRows = selected.filter(item => item.history.active && item.history.ended === null); -const pastRows = selected.filter(item => !(item.history.active && item.history.ended === null)); -const displayRows = []; -if (graphState.showActive && graphState.filter !== "ended" && activeRows.length) { -displayRows.push({ type: "group", label: `Living ${activeRows.length}` }); -for (const item of activeRows) displayRows.push({ type: "state", item }); -} -if (graphState.showPast && graphState.filter !== "active" && pastRows.length) { -displayRows.push({ type: "group", label: `Past ${pastRows.length}` }); -for (const item of pastRows) displayRows.push({ type: "state", item }); -} -const logicalHeight = paddingTop + -displayRows.reduce((sum, row) => sum + (row.type === "group" ? groupHeight : rowHeight), 0) + -paddingBottom; -canvas.style.height = `${logicalHeight}px`; -const updatedRect = canvas.getBoundingClientRect(); -const dpr = window.devicePixelRatio || 1; -const width = Math.max(1, Math.floor(updatedRect.width * dpr)); -const height = Math.max(1, Math.floor(logicalHeight * dpr)); -if (canvas.width !== width || canvas.height !== height) { -canvas.width = width; -canvas.height = height; -} -const g = canvas.getContext("2d"); -g.setTransform(dpr, 0, 0, dpr, 0, 0); -const w = updatedRect.width; -const h = logicalHeight; -g.clearRect(0, 0, w, h); -g.fillStyle = "#101315"; -g.fillRect(0, 0, w, h); -if (!histories.length || !selected.length || !displayRows.length) { -if (els.historyRange) els.historyRange.textContent = "-"; -graphState.rows = []; -graphState.markers = []; -graphState.scale = null; -g.fillStyle = "#7f8984"; -g.font = "12px ui-sans-serif, system-ui, sans-serif"; -g.fillText("No state history yet", 16, 28); -return; -} -let minYear = historyWindowStart; -let maxYear = historyWindowEnd; -for (const item of selected) { -const history = item.history; -minYear = Math.min(minYear, history.visibleFounded); -maxYear = Math.max(maxYear, history.ended ?? sim.year); -} -if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; -const plotWidth = Math.max(1, w - paddingLeft - paddingRight); -const yearToX = year => paddingLeft + ((clamp(year, minYear, maxYear) - minYear) / Math.max(1, maxYear - minYear)) * plotWidth; -const xToYear = x => minYear + ((clamp(x, paddingLeft, w - paddingRight) - paddingLeft) / plotWidth) * Math.max(1, maxYear - minYear); -const rows = []; -let cursorY = paddingTop; -for (const displayRow of displayRows) { -if (displayRow.type === "group") { -rows.push({ ...displayRow, y: cursorY + groupHeight * 0.5, height: groupHeight }); -cursorY += groupHeight; -continue; -} -const item = displayRow.item; -rows.push({ -...item, -type: "state", -y: cursorY + rowHeight * 0.5, -height: rowHeight, -color: item.history.color || hslToRgb((item.history.id * 0.38196601125) % 1, 0.58, 0.62), -samples: (item.history.visibleSamples || []) -.filter(sample => Number.isFinite(sample.year)) -.sort((a, b) => a.year - b.year) -}); -cursorY += rowHeight; -} -let minMetric = Infinity; -let maxMetric = -Infinity; -let minCities = Infinity; -let maxCities = -Infinity; -for (const row of rows.filter(row => row.type === "state")) { -for (const sample of row.samples) { -const value = graphSampleValue(sample, graphState.metric); -minMetric = Math.min(minMetric, value); -maxMetric = Math.max(maxMetric, value); -minCities = Math.min(minCities, sample.cities || 0); -maxCities = Math.max(maxCities, sample.cities || 0); -} -} -if (!Number.isFinite(minMetric) || !Number.isFinite(maxMetric)) { -minMetric = 0; -maxMetric = 1; -} -if (!Number.isFinite(minCities) || !Number.isFinite(maxCities)) { -minCities = 0; -maxCities = 1; -} -graphState.rows = rows.filter(row => row.type === "state"); -graphState.markers = []; -graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear }; -const axisY = h - paddingBottom + 4; -g.strokeStyle = "rgba(168, 177, 170, 0.22)"; -g.lineWidth = 1; -strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY); -g.fillStyle = "#8c9690"; -g.font = "10px ui-sans-serif, system-ui, sans-serif"; -g.textAlign = "left"; -g.fillText(formatGraphYear(minYear), paddingLeft, h - 5); -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, ${alpha})`; -g.lineWidth = 1; -strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y); -}); -rows.forEach(row => { -if (row.type !== "state") return; -const history = row.history; -g.textAlign = "left"; -g.fillStyle = "#a8b1aa"; -g.font = "9px ui-sans-serif, system-ui, sans-serif"; -g.fillText(`S${history.id}`, 4, row.y + 3); -const inactiveMultiplier = history.active ? 1 : 0.65; -const strokeSegment = (fromYear, toYear, metricValue, cities, alphaScale = 1) => { -if (toYear <= fromYear) return; -const normalizedMetric = (metricValue - minMetric) / Math.max(1, maxMetric - minMetric); -const metricT = clamp(normalizedMetric, 0, 1); -const cityT = clamp((cities - minCities) / Math.max(1, maxCities - minCities), 0, 1); -const lowColor = mix([56, 62, 60], row.color, 0.34); -const highColor = mix(row.color, [244, 246, 238], 0.18); -const segmentColor = mix(lowColor, highColor, metricT); -let alpha = (0.22 + metricT * 0.76) * inactiveMultiplier * alphaScale; -alpha = clamp(alpha, 0.10, 0.96); -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)})`; -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); -strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y); -g.lineCap = "butt"; -}; -if (row.samples.length > 1) { -if (history.visibleFounded < row.samples[0].year) { -strokeSegment(history.visibleFounded, row.samples[0].year, graphSampleValue(row.samples[0], graphState.metric), row.samples[0].cities, 0.42); -} -for (let i = 0; i < row.samples.length - 1; i++) { -const sampleA = row.samples[i]; -const sampleB = row.samples[i + 1]; -strokeSegment( -sampleA.year, -sampleB.year, -(graphSampleValue(sampleA, graphState.metric) + graphSampleValue(sampleB, graphState.metric)) * 0.5, -((sampleA.cities || 0) + (sampleB.cities || 0)) * 0.5 -); -} -const lastSample = row.samples[row.samples.length - 1]; -const endYear = history.ended ?? sim.year; -if (lastSample.year < endYear) { -strokeSegment(lastSample.year, endYear, graphSampleValue(lastSample, graphState.metric), lastSample.cities, 0.55); -} -} else { -const sample = row.samples[0] || { population: 0, cities: row.peakCities, avgLoyalty: 0.5 }; -strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValue(sample, graphState.metric), sample.cities || row.peakCities); -} -}); -rows.forEach(row => { -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 => graphEventTypes[event.type]); -const collisions = new Map(); -for (const event of events) { -const x = yearToX(event.year); -const bucket = Math.round(x / 7); -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; -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 }); -} -}); -} -function drawGraphCircle(g, x, y, r) { -g.beginPath(); -g.arc(x, y, r, 0, Math.PI * 2); -g.fill(); -} -function drawGraphPolygon(g, points) { -g.beginPath(); -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) { -drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]); -} -function drawGraphTriangleDown(g, x, y, r) { -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) { -return graphEventTypes[event.type]?.label || event.type; -} -function graphEventSummary(event) { -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 = ` - ${title} - ${formatGraphYear(marker.event.year)} - ${graphEventSummary(marker.event)} - `; -els.stateGraphTooltip.hidden = false; -const scroll = els.stateGraph.parentElement; -const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8); -const left = clamp(pointerX + 10, 8, maxLeft); -const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10); -els.stateGraphTooltip.style.left = `${left}px`; -els.stateGraphTooltip.style.top = `${top}px`; -} -function hideStateGraphMarkerTooltip() { -if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true; -} -function updateStateGraphInfo(event) { -if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return; -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); -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); -const nearestRow = bestBy(row, item => -item.distance)?.candidate; -if (!nearestRow) { -els.stateGraphInfo.textContent = "Hover a row for details"; -hideStateGraphMarkerTooltip(); -if (graphState.hoverPolityId !== null) { -graphState.hoverPolityId = null; -if (!running) render(); -} -return; -} -if (graphState.hoverPolityId !== nearestRow.history.id) { -graphState.hoverPolityId = nearestRow.history.id; -if (!running) render(); -} -hideStateGraphMarkerTooltip(); -const year = graphState.scale.xToYear(x); -const samples = nearestRow.samples.length ? nearestRow.samples : [{ -year, -population: 0, -cities: nearestRow.peakCities, -avgLoyalty: 0.5, -power: nearestRow.peakPower -}]; -const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year)); -const metricValue = graphSampleValue(sample, graphState.metric); -const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past"; -els.stateGraphInfo.textContent = -`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()} ` + -`loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`; -} -function clearStateGraphInfo() { -if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details"; -hideStateGraphMarkerTooltip(); -if (graphState.hoverPolityId !== null) { -graphState.hoverPolityId = null; -if (!running) render(); -} -} -function formatGraphYear(week) { -return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`; -} -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; -} -function drawFrame(forceStats = false) { -render(); -updateStats(forceStats); -renderTooltip(); -renderDirty = false; -lastRenderAt = performance.now(); -} -function scheduleLoop() { -if (loopScheduled) return; -loopScheduled = true; -const delay = document.hidden -? LoopConfig.hiddenPollMs -: running ? 0 : LoopConfig.idlePollMs; -if (delay > 0) { -loopTimer = setTimeout(() => { -loopTimer = null; -requestAnimationFrame(loop); -}, delay); -} else { -requestAnimationFrame(loop); -} -} -function loop(now = performance.now()) { -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 > stepBudget) break; -} -if (completedSteps > 0) requestRender(); -} -const shouldRender = !document.hidden && -(renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs)); -if (shouldRender) drawFrame(); -scheduleLoop(); -} -function reset() { -const size = Number(els.worldSize.value); -const count = Number(els.agentCount.value); -els.canvas.width = size; -els.canvas.height = size; -renderImage = null; -renderImageSize = 0; -sim = new Simulation(size, count); -setLegend(); -drawFrame(true); -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); -} -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 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 Object.fromEntries(traitFields.map(([key, get]) => [key, lerp(get(a), get(b), t)])); -} -function emptyTraitSums() { -return Object.fromEntries(traitFields.map(([key]) => [key, 0])); -} -function addTraits(sum, traits) { -for (const [key, get, , , , sumScale] of traitFields) sum[key] += get(traits) * sumScale; -} -function averageTraits(sum, count) { -return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale])); -} -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 = compositionTotal(composition); -if (!total) return; -for (const [id, count] of [...composition]) { -const removed = Math.min(count, Math.ceil(loss * (count / total))); -composition.set(id, Math.max(0, count - removed)); -remaining -= removed; -if (composition.get(id) <= 0) composition.delete(id); -if (remaining <= 0) break; -} -} -function dominantComposition(composition) { -let bestId = null; -let bestCount = 0; -for (const [id, count] of composition) { -if (count > bestCount) { -bestId = id; -bestCount = count; -} -} -return bestId; -} -function traitDistance(a, 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) => { -if (t < 0) t += 1; -if (t > 1) t -= 1; -if (t < 1 / 6) return p + (q - p) * 6 * t; -if (t < 1 / 2) return q; -if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; -return p; -}; -const q = l < 0.5 ? l * (1 + s) : l + s - l * s; -const p = 2 * l - q; -return [ -Math.round(hue(p, q, h + 1 / 3) * 255), -Math.round(hue(p, q, h) * 255), -Math.round(hue(p, q, h - 1 / 3) * 255) -]; -} -function mix(a, b, t) { -return [ -Math.round(lerp(a[0], b[0], t)), -Math.round(lerp(a[1], b[1], t)), -Math.round(lerp(a[2], b[2], t)) -]; -} -function lerp(a, b, t) { -return a + (b - a) * t; -} -function refreshStateGraph() { -renderStateGraph(); -clearStateGraphInfo(); -} -els.toggleRun.addEventListener("click", () => { -running = !running; -els.toggleRun.textContent = running ? "Pause" : "Run"; -lastSimTickAt = 0; -if (running && loopTimer) { -clearTimeout(loopTimer); -loopTimer = null; -loopScheduled = false; -scheduleLoop(); -} else if (running) { -scheduleLoop(); -} -}); -els.stepOnce.addEventListener("click", () => { -sim.step(); -requestRender(); -drawFrame(true); -renderStateGraph(); -}); -els.resetWorld.addEventListener("click", reset); -for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset); -els.viewMode.addEventListener("change", () => { -setLegend(); -requestRender(); -drawFrame(); -}); -els.canvas.addEventListener("mousemove", showTooltip); -els.canvas.addEventListener("mouseleave", hideTooltip); -for (const control of [els.historyFilter, els.historySort, els.historyMetric]) { -control?.addEventListener("change", refreshStateGraph); -} -els.toggleActiveStates?.addEventListener("click", () => { -graphState.showActive = !graphState.showActive; -els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); -refreshStateGraph(); -}); -els.togglePastStates?.addEventListener("click", () => { -graphState.showPast = !graphState.showPast; -els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); -refreshStateGraph(); -}); -els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); -els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); -document.addEventListener("visibilitychange", () => { -lastSimTickAt = 0; -requestRender(); -if (!document.hidden && loopTimer) { -clearTimeout(loopTimer); -loopTimer = null; -loopScheduled = false; -scheduleLoop(); -} -}); -reset(); -loop(); diff --git a/index.html b/index.html index 269d45d..0e6abfb 100644 --- a/index.html +++ b/index.html @@ -96,8 +96,6 @@