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