ethnicity

This commit is contained in:
33333-33333 2026-05-14 00:21:24 +09:00
commit 01ab8edd9b
3 changed files with 435 additions and 59 deletions

View file

@ -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
View file

@ -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;

View file

@ -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;